diff --git a/backend/src/controllers/course.controller.ts b/backend/src/controllers/course.controller.ts index 00d161185..2d0698ceb 100644 --- a/backend/src/controllers/course.controller.ts +++ b/backend/src/controllers/course.controller.ts @@ -107,6 +107,30 @@ export class CourseController implements IController { } }, ) + .get( + "/course/filter/:terms/:faculties/:searchTerm", + async (req: Request, res: Response, next: NextFunction) => { + this.logger.debug(`Received request in GET /course/filter`); + try { + const { terms, faculties, searchTerm } = req.params; + + const result = await this.courseService.filterCourse( + terms, + faculties, + searchTerm, + ); + + return res.status(200).json(result); + } catch (err: any) { + this.logger.warn( + `An error occurred when trying to GET /course/filter ${formatError( + err, + )}`, + ); + return next(err); + } + }, + ) .delete( "/cached/flush", async ( diff --git a/backend/src/repositories/course.repository.ts b/backend/src/repositories/course.repository.ts index b848ec22d..dcc2621f5 100644 --- a/backend/src/repositories/course.repository.ts +++ b/backend/src/repositories/course.repository.ts @@ -4,6 +4,8 @@ import { CourseCodeSchema, CourseSchema, } from "../api/schemas/course.schema"; +import e from "express"; +import { Console } from "console"; export class CourseRepository { constructor(private readonly prisma: PrismaClient) {} @@ -184,8 +186,8 @@ export class CourseRepository { LEFT JOIN reviews r ON c.course_code = r.course_code WHERE c.course_code ILIKE ${searchQuery} OR c.title ILIKE ${searchQuery} GROUP BY c.course_code - ORDER BY - CASE + ORDER BY + CASE WHEN c.course_code ILIKE ${searchQuery} THEN 1 WHEN c.title ILIKE ${searchQuery} THEN 2 ELSE 3 @@ -196,4 +198,159 @@ export class CourseRepository { const courses = rawCourses.map((course) => CourseSchema.parse(course)); return courses; } + + async filterCourse( + terms: string, + faculties: string, + searchTerm: string, + ): Promise { + // default filters (all options) + let searchQuery = `%`; + let termFilters = ["0", "1", "2", "3", "-1", "-2"]; + let facultyFilters = [ + "%arts%", + "%business%", + "%engineering%", + "%law%", + "%medicine%", + "%science%", + "%unsw canberra%", + ]; + + if (searchTerm !== "_") { + searchQuery = `%${searchTerm}%`; + } + + // there are selected terms + if (terms !== "_") { + // 0&1&2 => ["0", "1", "2"]; + termFilters = terms.split("&"); + } + + // there are selected faculties + if (faculties !== "_") { + // ['arts', 'law'] => `'%arts%', '%law%'` + facultyFilters = faculties.split("&").map((faculty) => `%${faculty}%`); + const index = facultyFilters.indexOf("%UNSW_Canberra%"); + if (index !== -1) { + facultyFilters[index] = "%unsw canberra%"; + } + } + + const rawCourses = (await this.prisma.$queryRaw` + SELECT + c.course_code AS "courseCode", + c.archived, + c.attributes, + c.calendar, + c.campus, + c.description, + c.enrolment_rules AS "enrolmentRules", + c.equivalents, + c.exclusions, + c.faculty, + c.field_of_education AS "fieldOfEducation", + c.gen_ed AS "genEd", + c.level, + c.school, + c.study_level AS "studyLevel", + c.terms, + c.title, + c.uoc, + AVG(r.overall_rating) AS "overallRating", + AVG(r.manageability) AS "manageability", + AVG(r.usefulness) AS "usefulness", + AVG(r.enjoyability) AS "enjoyability", + CAST(COUNT(r.review_id) AS INT) AS "reviewCount" + FROM courses c + LEFT JOIN reviews r ON c.course_code = r.course_code + WHERE (c.course_code ILIKE ${searchQuery} OR c.title ILIKE ${searchQuery}) AND + c.terms && ${termFilters}::integer[] AND + c.faculty ILIKE ANY(${facultyFilters}) + GROUP BY c.course_code + ORDER BY "reviewCount" DESC; + `) as any[]; + const courses = rawCourses.map((course) => CourseSchema.parse(course)); + return courses; + } + + async filterNotOfferedCourses( + terms: string, + faculties: string, + searchTerm: string, + ): Promise { + // default filters (all options) + let searchQuery = `%`; + let termFilters: number[] = []; + let facultyFilters = [ + "%arts%", + "%business%", + "%engineering%", + "%law%", + "%medicine%", + "%science%", + "%unsw canberra%", + ]; + + if (searchTerm !== "_") { + searchQuery = `%${searchTerm}%`; + } + + // there are selected terms + if (terms !== "_") { + // 0&1&2 => ["0", "1", "2"]; + + termFilters = terms + .split("&") + .filter((term) => term !== "None") + .map((term) => parseInt(term, 10)); + } + + // there are selected faculties + if (faculties !== "_") { + // ['arts', 'law'] => `'%arts%', '%law%'` + facultyFilters = faculties.split("&").map((faculty) => `%${faculty}%`); + const index = facultyFilters.indexOf("%UNSW_Canberra%"); + if (index !== -1) { + facultyFilters[index] = "%unsw canberra%"; + } + } + + const rawCourses = (await this.prisma.$queryRaw` + SELECT + c.course_code AS "courseCode", + c.archived, + c.attributes, + c.calendar, + c.campus, + c.description, + c.enrolment_rules AS "enrolmentRules", + c.equivalents, + c.exclusions, + c.faculty, + c.field_of_education AS "fieldOfEducation", + c.gen_ed AS "genEd", + c.level, + c.school, + c.study_level AS "studyLevel", + c.terms, + c.title, + c.uoc, + AVG(r.overall_rating) AS "overallRating", + AVG(r.manageability) AS "manageability", + AVG(r.usefulness) AS "usefulness", + AVG(r.enjoyability) AS "enjoyability", + CAST(COUNT(r.review_id) AS INT) AS "reviewCount" + FROM courses c + LEFT JOIN reviews r ON c.course_code = r.course_code + WHERE (c.course_code ILIKE ${searchQuery} OR c.title ILIKE ${searchQuery}) AND + (c.terms = ARRAY[]::integer[] OR c.terms && ${termFilters}::integer[]) AND + c.faculty ILIKE ANY(${facultyFilters}) + GROUP BY c.course_code + ORDER BY "reviewCount" DESC; + `) as any[]; + const courses = rawCourses.map((course) => CourseSchema.parse(course)); + + return courses; + } } diff --git a/backend/src/services/course.service.ts b/backend/src/services/course.service.ts index c19fad6da..2dd7990a1 100644 --- a/backend/src/services/course.service.ts +++ b/backend/src/services/course.service.ts @@ -88,6 +88,48 @@ export class CourseService { return { courses }; } + async filterCourse( + terms: string, + faculties: string, + searchTerm: string, + ): Promise { + let courses = await this.redis.get( + `filterCourses:${terms}&${faculties}&${searchTerm}`, + ); + if (!courses) { + this.logger.info( + `Cache miss on filterCourses:${terms}&${faculties}&${searchTerm}`, + ); + + if (terms.includes("None")) { + // filters for not offered courses + courses = await this.courseRepository.filterNotOfferedCourses( + terms, + faculties, + searchTerm, + ); + } else { + courses = await this.courseRepository.filterCourse( + terms, + faculties, + searchTerm, + ); + } + + await this.redis.set( + `filterCourses:${terms}&${faculties}&${searchTerm}`, + courses, + ); + } else { + this.logger.info( + `Cache hit on filterCourses:${terms}&${faculties}&${searchTerm}`, + ); + } + + this.logger.info(`Found ${courses.length} courses.`); + return { courses }; + } + async flushKey(zid: string, key: string) { const userInfo = await this.userRepository.getUser(zid); if (!userInfo) { diff --git a/frontend/src/components/CoursesList/CoursesList.tsx b/frontend/src/components/CoursesList/CoursesList.tsx index cc7bef8d5..ccf8599bd 100644 --- a/frontend/src/components/CoursesList/CoursesList.tsx +++ b/frontend/src/components/CoursesList/CoursesList.tsx @@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react"; import { get } from "@/utils/request"; import { sortCourses } from "@/utils/sortCourses"; import SortDropdown from "../SortDropdown/SortDropdown"; +import FilterModal from "../FilterModal/FilterModal"; export default function CoursesList({ initialCourses, @@ -17,18 +18,34 @@ export default function CoursesList({ const courseFinishedRef = useRef(false); const indexRef = useRef(initialCourses.length); const searchCoursesRef = useRef([]); + const filteredCoursesRef = useRef([]); const [displayCourses, setDisplayCourses] = useState(initialCourses); const [initialLoading, setInitialLoading] = useState(true); const [selected, setSelected] = useState(""); - + const [filters, setFilters] = useState<{ + faculties: string[]; + terms: string[]; + }>({ + faculties: [], + terms: [], + }); const paginationOffset = 25; const loadMore = async (index: number) => { const fetchCourses = async () => { let fetchedCourses: Course[] = []; - if (searchTerm === "") { + + // there are applied filters and search + if ( + searchTerm !== "" || + filters.faculties.length !== 0 || + filters.terms.length !== 0 + ) { + // filtered courses based on search + filter (if any) + fetchedCourses = filteredCoursesRef.current.slice(index, index + 25); + } else { // default courses try { const { courses } = (await get( @@ -38,9 +55,6 @@ export default function CoursesList({ } catch (err) { fetchedCourses = []; } - } else { - // searched courses - fetchedCourses = searchCoursesRef.current.slice(index, index + 25); } return fetchedCourses; @@ -62,33 +76,56 @@ export default function CoursesList({ setDisplayCourses((prev) => [...prev, ...courses]); }; + // filters courses based on search + selected filters + const getFilterResults = async () => { + let terms = filters.terms.join("&"); + let faculties = filters.faculties.join("&"); + + if (terms === "") { + terms = "_"; + } + if (faculties === "") { + faculties = "_"; + } + + if (searchTerm === "") { + searchTerm = "_"; + } + + // EXAMPLE URL: /course/filter/1&3/art&engineering/comp + try { + const { courses } = (await get( + `/course/filter/${terms}/${faculties}/${searchTerm}`, + )) as Courses; + filteredCoursesRef.current = courses; + } catch (err) { + filteredCoursesRef.current = []; + } + setDisplayCourses(filteredCoursesRef.current.slice(0, paginationOffset)); + indexRef.current += paginationOffset; + setInitialLoading(false); + }; + useEffect(() => { const resetRefs = () => { courseFinishedRef.current = false; indexRef.current = initialCourses.length; - searchCoursesRef.current = []; - }; - const getSearchResults = async () => { - try { - const { courses } = (await get( - `/course/search/${searchTerm}`, - )) as Courses; - searchCoursesRef.current = courses; - } catch (err) { - searchCoursesRef.current = []; - } - setDisplayCourses(searchCoursesRef.current.slice(0, paginationOffset)); - indexRef.current += paginationOffset; - setInitialLoading(false); + filteredCoursesRef.current = []; }; + const getInitialDisplayCourses = () => { - if (searchTerm !== "") { - getSearchResults(); + if ( + searchTerm !== "" || + filters.faculties.length !== 0 || + filters.terms.length !== 0 + ) { + getFilterResults(); } else { setDisplayCourses(initialCourses.slice(0, paginationOffset)); setInitialLoading(false); } }; + const loadOnScroll = () => { if ( window.innerHeight + window.pageYOffset >= document.body.offsetHeight && @@ -104,13 +141,16 @@ export default function CoursesList({ window.addEventListener("scroll", loadOnScroll); return () => window.removeEventListener("scroll", loadOnScroll); - }, [searchTerm]); + }, [searchTerm, filters]); return ( <> - {/* SortDropdown Bar */} - -
+ {/* SortDropdown Bar and Filter Buttion*/} +
+ + +
+ diff --git a/frontend/src/components/FilterModal/FilterModal.tsx b/frontend/src/components/FilterModal/FilterModal.tsx new file mode 100644 index 000000000..9e94df921 --- /dev/null +++ b/frontend/src/components/FilterModal/FilterModal.tsx @@ -0,0 +1,240 @@ +"use client"; +import React from "react"; +import Dropdown from "../Dropdown/Dropdown"; +import { useState } from "react"; +import { Dialog } from "@headlessui/react"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { AdjustmentsHorizontalIcon } from "@heroicons/react/24/outline"; +import { Course } from "@/types/api"; + +export default function FilterModal({ + filters, + setFilters, +}: { + filters: { terms: string[]; faculties: string[] }; + setFilters: React.Dispatch< + React.SetStateAction<{ faculties: string[]; terms: string[] }> + >; +}) { + const faculties = [ + "Arts", + "Business", + "Engineering", + "Law", + "Medicine", + "Science", + "UNSW Canberra", + ]; + + const terms = [ + "Summer", + "Term 1", + "Term 2", + "Term 3", + "Semester 1", + "Semester 2", + "Not Offered", + ]; + + const termsShortened = [ + "Summer", + "T1", + "T2", + "T3", + "Sem 1", + "Sem 2", + "Not Offered", + ]; + + const [facultiesCheckedState, setFacultiesCheckedState] = useState( + new Array(faculties.length).fill(false), + ); + + const [termsCheckedState, setTermsCheckedState] = useState( + new Array(terms.length).fill(false), + ); + + const [open, setOpen] = useState(false); + + const handleClose = () => { + // if no filters were applied then clear all + if (filters.terms.length === 0 && filters.faculties.length === 0) { + handleClearAll(); + } + setOpen(false); + }; + + const handleClearAll = () => { + setTermsCheckedState(new Array(terms.length).fill(false)); + setFacultiesCheckedState(new Array(faculties.length).fill(false)); + }; + + const handleApply = () => { + const selectedFaculties: string[] = []; + const selectedTerms: string[] = []; + + faculties.map((faculty, index) => { + // if selected + if (facultiesCheckedState[index]) { + if (faculty === "UNSW Canberra") { + selectedFaculties.push("UNSW_Canberra"); + } else { + selectedFaculties.push(faculty); + } + } + }); + + // terms are 0, 1, 2, 3 (summer, t1, t2, t3) + // semesters are -1, -2 + // not offered is "None" + terms.map((term, index) => { + // if selected + if (termsCheckedState[index]) { + if (term === "Not Offered") { + selectedTerms.push("None"); + } else if (term === "Summer") { + selectedTerms.push("0"); + } else { + let termString = term.split(" "); + if (termString[0] === "Term") { + selectedTerms.push(termString[1]); + } else if (termString[0] === "Semester") { + selectedTerms.push(`-${termString[1]}`); + } + } + } + }); + + setFilters({ faculties: selectedFaculties, terms: selectedTerms }); + + setOpen(false); + }; + + const handleTagOnClick = (type: string, position: number) => { + if (type === "faculty") { + const updatedCheckedState = facultiesCheckedState.map((item, index) => + index === position ? !item : item, + ); + setFacultiesCheckedState(updatedCheckedState); + } else if (type === "term") { + const updatedCheckedState = termsCheckedState.map((item, index) => + index === position ? !item : item, + ); + setTermsCheckedState(updatedCheckedState); + } + }; + + const styledFilterButton = ( + type: string, + index: number, + isChecked: boolean, + label: string, + ) => { + return ( + + ); + }; + + return ( + <> + {/* filter button */} +
+ +
+ + {/* filter dialog */} + + {/* the blurred backdrop */} + + + ); +} diff --git a/frontend/src/components/SortDropdown/SortDropdown.tsx b/frontend/src/components/SortDropdown/SortDropdown.tsx index 8767dc575..69743d123 100644 --- a/frontend/src/components/SortDropdown/SortDropdown.tsx +++ b/frontend/src/components/SortDropdown/SortDropdown.tsx @@ -1,6 +1,6 @@ -"use client"; -import React from "react"; -import Dropdown from "../Dropdown/Dropdown"; +'use client'; +import React from 'react'; +import Dropdown from '../Dropdown/Dropdown'; export default function SortDropdownBar({ selected, @@ -10,16 +10,16 @@ export default function SortDropdownBar({ setSelected: (str: string) => void; }) { return ( -
-
+
+