From 73a411195a2108126fe756b8e7354dd6c75a1f96 Mon Sep 17 00:00:00 2001 From: Matthew Rowland Date: Tue, 2 Jul 2024 12:05:46 -0700 Subject: [PATCH] fix: CourseBrowser, ClassBrowser --- frontend/schema.graphql | 114 ++++++++ frontend/src/app/Catalog/index.tsx | 4 +- frontend/src/app/Plan/Term/Catalog/index.tsx | 26 +- frontend/src/app/Plan/Term/index.tsx | 2 +- .../Schedule/Manage/SideBar/Catalog/index.tsx | 4 +- .../ClassBrowser.module.scss} | 0 .../Filters/Filters.module.scss | 0 .../Filters/index.tsx | 0 .../Header/Header.module.scss | 0 .../Header/index.tsx | 0 .../List/Class/Class.module.scss | 0 .../List/Class/index.tsx | 0 .../List/List.module.scss | 0 .../{Browser => ClassBrowser}/List/index.tsx | 0 .../{Browser => ClassBrowser}/browser.ts | 0 .../{Browser => ClassBrowser}/index.tsx | 8 +- .../{Browser => ClassBrowser}/worker.ts | 0 .../CourseBrowser/CourseBrowser.module.scss | 9 + .../CourseBrowser/Filters/Filters.module.scss | 136 +++++++++ .../CourseBrowser/Filters/index.tsx | 276 ++++++++++++++++++ .../CourseBrowser/Header/Header.module.scss | 42 +++ .../components/CourseBrowser/Header/index.tsx | 91 ++++++ .../List/Course/Course.module.scss | 59 ++++ .../CourseBrowser/List/Course/index.tsx | 43 +++ .../CourseBrowser/List/List.module.scss | 88 ++++++ .../components/CourseBrowser/List/index.tsx | 135 +++++++++ .../src/components/CourseBrowser/browser.ts | 143 +++++++++ .../src/components/CourseBrowser/index.tsx | 179 ++++++++++++ frontend/src/lib/api.ts | 59 +++- 29 files changed, 1385 insertions(+), 33 deletions(-) rename frontend/src/components/{Browser/Browser.module.scss => ClassBrowser/ClassBrowser.module.scss} (100%) rename frontend/src/components/{Browser => ClassBrowser}/Filters/Filters.module.scss (100%) rename frontend/src/components/{Browser => ClassBrowser}/Filters/index.tsx (100%) rename frontend/src/components/{Browser => ClassBrowser}/Header/Header.module.scss (100%) rename frontend/src/components/{Browser => ClassBrowser}/Header/index.tsx (100%) rename frontend/src/components/{Browser => ClassBrowser}/List/Class/Class.module.scss (100%) rename frontend/src/components/{Browser => ClassBrowser}/List/Class/index.tsx (100%) rename frontend/src/components/{Browser => ClassBrowser}/List/List.module.scss (100%) rename frontend/src/components/{Browser => ClassBrowser}/List/index.tsx (100%) rename frontend/src/components/{Browser => ClassBrowser}/browser.ts (100%) rename frontend/src/components/{Browser => ClassBrowser}/index.tsx (98%) rename frontend/src/components/{Browser => ClassBrowser}/worker.ts (100%) create mode 100644 frontend/src/components/CourseBrowser/CourseBrowser.module.scss create mode 100644 frontend/src/components/CourseBrowser/Filters/Filters.module.scss create mode 100644 frontend/src/components/CourseBrowser/Filters/index.tsx create mode 100644 frontend/src/components/CourseBrowser/Header/Header.module.scss create mode 100644 frontend/src/components/CourseBrowser/Header/index.tsx create mode 100644 frontend/src/components/CourseBrowser/List/Course/Course.module.scss create mode 100644 frontend/src/components/CourseBrowser/List/Course/index.tsx create mode 100644 frontend/src/components/CourseBrowser/List/List.module.scss create mode 100644 frontend/src/components/CourseBrowser/List/index.tsx create mode 100644 frontend/src/components/CourseBrowser/browser.ts create mode 100644 frontend/src/components/CourseBrowser/index.tsx diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 2d58ce8f2..13927eac7 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -153,7 +153,9 @@ type Course { title: String! toDate: String! raw: JSONObject! + primaryInstructionMethod: InstructionMethod! lastUpdated: ISODate! + typicallyOffered: [Semester!] } enum AcademicCareer { @@ -463,6 +465,118 @@ enum Component { SEM } +enum InstructionMethod { + """ + Unknown + """ + UNK + + """ + Demonstration + """ + DEM + + """ + Conversation + """ + CON + + """ + Workshop + """ + WOR + + """ + Web-Based Discussion + """ + WBD + + """ + Clinic + """ + CLC + + """ + Directed Group Study + """ + GRP + + """ + Discussion + """ + DIS + + """ + Tutorial + """ + TUT + + """ + Field Work + """ + FLD + + """ + Lecture + """ + LEC + + """ + Laboratory + """ + LAB + + """ + Session + """ + SES + + """ + Studio + """ + STD + + """ + Self-paced + """ + SLF + + """ + Colloquium + """ + COL + + """ + Web-Based Lecture + """ + WBL + + """ + Independent Study + """ + IND + + """ + Internship + """ + INT + + """ + Reading + """ + REA + + """ + Recitation + """ + REC + + """ + Seminar + """ + SEM +} + type Reservation { enrollCount: Int! enrollMax: Int! diff --git a/frontend/src/app/Catalog/index.tsx b/frontend/src/app/Catalog/index.tsx index 51b363d52..a32ed01c8 100644 --- a/frontend/src/app/Catalog/index.tsx +++ b/frontend/src/app/Catalog/index.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from "react"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; -import Browser from "@/components/Browser"; +import ClassBrowser from "@/components/ClassBrowser"; import { IClass, Semester } from "@/lib/api"; import styles from "./Catalog.module.scss"; @@ -53,7 +53,7 @@ export default function Catalog() { return (
- void; + onClick: (course: ICourse) => void; children: ReactNode; - semester: Semester; - year: number; } -export default function Catalog({ - onClick, - children, - semester, - year, -}: CatalogProps) { +export default function Catalog({ onClick, children }: CatalogProps) { const [open, setOpen] = useState(false); const [searchParams, setSearchParams] = useSearchParams(); @@ -35,8 +28,8 @@ export default function Catalog({ setSearchParams(searchParams); }; - const handleClick = (course: ICourse, number: string) => { - onClick(course, number); + const handleClick = (course: ICourse) => { + onClick(course); setOpen(false); @@ -59,12 +52,7 @@ export default function Catalog({
- +
diff --git a/frontend/src/app/Plan/Term/index.tsx b/frontend/src/app/Plan/Term/index.tsx index 3456424a5..883369f7d 100644 --- a/frontend/src/app/Plan/Term/index.tsx +++ b/frontend/src/app/Plan/Term/index.tsx @@ -28,7 +28,7 @@ const Term = forwardRef( {(units) =>

{units}

}
- + diff --git a/frontend/src/app/Schedule/Manage/SideBar/Catalog/index.tsx b/frontend/src/app/Schedule/Manage/SideBar/Catalog/index.tsx index 3c389ba93..27b82a62f 100644 --- a/frontend/src/app/Schedule/Manage/SideBar/Catalog/index.tsx +++ b/frontend/src/app/Schedule/Manage/SideBar/Catalog/index.tsx @@ -4,7 +4,7 @@ import * as Dialog from "@radix-ui/react-dialog"; import { Xmark } from "iconoir-react"; import { useSearchParams } from "react-router-dom"; -import Browser from "@/components/Browser"; +import ClassBrowser from "@/components/ClassBrowser"; import IconButton from "@/components/IconButton"; import { ICourse, Semester } from "@/lib/api"; @@ -54,7 +54,7 @@ export default function Catalog({ onClassSelect, children }: CatalogProps) {
- void; responsive?: boolean; semester: Semester; @@ -33,13 +33,13 @@ interface BrowserProps { persistent?: boolean; } -export default function Browser({ +export default function ClassBrowser({ onSelect, responsive = true, semester: currentSemester, year: currentYear, persistent, -}: BrowserProps) { +}: ClassBrowserProps) { const [open, setOpen] = useState(false); const [searchParams] = useSearchParams(); const { width } = useWindowDimensions(); diff --git a/frontend/src/components/Browser/worker.ts b/frontend/src/components/ClassBrowser/worker.ts similarity index 100% rename from frontend/src/components/Browser/worker.ts rename to frontend/src/components/ClassBrowser/worker.ts diff --git a/frontend/src/components/CourseBrowser/CourseBrowser.module.scss b/frontend/src/components/CourseBrowser/CourseBrowser.module.scss new file mode 100644 index 000000000..e824c7faa --- /dev/null +++ b/frontend/src/components/CourseBrowser/CourseBrowser.module.scss @@ -0,0 +1,9 @@ +.root { + position: relative; + height: 100%; + display: flex; + + &.block { + width: 100%; + } +} \ No newline at end of file diff --git a/frontend/src/components/CourseBrowser/Filters/Filters.module.scss b/frontend/src/components/CourseBrowser/Filters/Filters.module.scss new file mode 100644 index 000000000..4aaf1ce77 --- /dev/null +++ b/frontend/src/components/CourseBrowser/Filters/Filters.module.scss @@ -0,0 +1,136 @@ +.root { + width: 384px; + flex-shrink: 0; + overflow: auto; + background-color: var(--foreground-color); + + &:not(.block) { + border-right: 1px solid var(--border-color); + } + + &.block { + width: 100%; + } + + &.overlay .body { + padding-top: 12px; + } + + .body { + display: flex; + flex-direction: column; + padding: 24px; + + .button { + display: flex; + align-items: center; + gap: 12px; + font-size: 14px; + color: var(--blue-500); + line-height: 1; + cursor: pointer; + font-weight: 500; + margin-top: 12px; + transition: all 100ms ease-in-out; + + &:hover { + color: var(--blue-600); + } + } + + .label { + font-size: 14px; + color: var(--label-color); + line-height: 1; + margin-bottom: 12px; + + &:not(:first-child) { + margin-top: 24px; + } + } + + .filter { + display: flex; + align-items: center; + gap: 12px; + + & + .filter { + margin-top: 12px; + } + + &:hover .text .value { + color: var(--heading-color); + } + + &:hover .checkbox:not([data-state="checked"]), &:hover .radio:not([data-state="checked"]) { + border-color: var(--heading-color); + } + + .text { + font-size: 14px; + color: var(--label-color); + line-height: 1; + flex-grow: 1; + + .value { + font-weight: 500; + color: var(--paragraph-color); + } + } + + .radio { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid var(--paragraph-color); + position: relative; + + &::after { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--blue-500); + opacity: 0; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + &[data-state="checked"] { + border-color: var(--blue-500); + + &::after { + opacity: 1; + } + + & + .text .value { + color: var(--heading-color); + } + } + } + + .checkbox { + width: 16px; + height: 16px; + display: grid; + place-items: center; + border-radius: 4px; + + &[data-state="checked"] { + background-color: var(--blue-500); + color: white; + + & + .text .value { + color: var(--heading-color); + } + } + + &[data-state="unchecked"] { + border: 2px solid var(--paragraph-color); + } + } + } + } +} \ No newline at end of file diff --git a/frontend/src/components/CourseBrowser/Filters/index.tsx b/frontend/src/components/CourseBrowser/Filters/index.tsx new file mode 100644 index 000000000..e35e5e93e --- /dev/null +++ b/frontend/src/components/CourseBrowser/Filters/index.tsx @@ -0,0 +1,276 @@ +import { useMemo, useState } from "react"; + +import * as Checkbox from "@radix-ui/react-checkbox"; +import * as RadioGroup from "@radix-ui/react-radio-group"; +import classNames from "classnames"; +import { Check, NavArrowDown, NavArrowUp } from "iconoir-react"; +import { useSearchParams } from "react-router-dom"; + +import { ICourse, InstructionMethod, instructionMethods } from "@/lib/api"; + +import Header from "../Header"; +import { Level, SortBy, getFilteredCourses, getLevel } from "../browser"; +import styles from "./Filters.module.scss"; + +interface FiltersProps { + overlay: boolean; + block: boolean; + includedCourses: ICourse[]; + excludedCourses: ICourse[]; + currentCourses: ICourse[]; + currentInstructionMethods: InstructionMethod[]; + currentLevels: Level[]; + onOpenChange: (open: boolean) => void; + open: boolean; + currentQuery: string; + currentSortBy: SortBy; + setCurrentSortBy: (sortBy: SortBy) => void; + setCurrentQuery: (query: string) => void; + setCurrentInstructionMethods: (components: InstructionMethod[]) => void; + setCurrentLevels: (levels: Level[]) => void; + persistent?: boolean; +} + +export default function Filters({ + overlay, + block, + includedCourses, + excludedCourses, + currentCourses, + currentInstructionMethods, + currentLevels, + onOpenChange, + open, + currentQuery, + currentSortBy, + setCurrentSortBy, + setCurrentQuery, + setCurrentInstructionMethods, + setCurrentLevels, + persistent, +}: FiltersProps) { + const [expanded, setExpanded] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); + + const filteredLevels = useMemo(() => { + const courses = + currentLevels.length === 0 + ? includedCourses + : getFilteredCourses(excludedCourses, currentInstructionMethods, []) + .includedCourses; + + return courses.reduce( + (acc, course) => { + const level = getLevel(course.academicCareer, course.number); + + acc[level] += 1; + + return acc; + }, + { + "Lower Division": 0, + "Upper Division": 0, + Graduate: 0, + Extension: 0, + } as Record + ); + }, [ + excludedCourses, + includedCourses, + currentInstructionMethods, + currentLevels, + ]); + + const filteredInstructionMethods = useMemo(() => { + const filteredInstructionMethods = Object.keys(instructionMethods).reduce( + (acc, component) => { + acc[component] = 0; + return acc; + }, + {} as Record + ); + + const courses = + currentInstructionMethods.length === 0 + ? includedCourses + : getFilteredCourses(excludedCourses, [], currentLevels) + .includedCourses; + + for (const course of courses) { + const { primaryInstructionMethod } = course; + + filteredInstructionMethods[primaryInstructionMethod] += 1; + } + + return filteredInstructionMethods; + }, [ + excludedCourses, + includedCourses, + currentInstructionMethods, + currentLevels, + ]); + + const update = ( + name: string, + state: T[], + setState: (state: T[]) => void, + value: T, + checked: boolean + ) => { + if (persistent) { + if (checked) { + searchParams.set(name, [...state, value].join(",")); + } else { + const filtered = state.filter((parameter) => parameter !== value); + + if (filtered.length > 0) { + searchParams.set(name, filtered.join(",")); + } else { + searchParams.delete(name); + } + } + + setSearchParams(searchParams); + + return; + } + + setState( + checked + ? [...state, value] + : state.filter((parameter) => parameter !== value) + ); + }; + + const handleValueChange = (value: SortBy) => { + if (persistent) { + if (value === SortBy.Relevance) searchParams.delete("sortBy"); + else searchParams.set("sortBy", value); + setSearchParams(searchParams); + + return; + } + + console.log(value); + setCurrentSortBy(value); + }; + + return ( +
+ {open && overlay && ( +
+ )} +
+

Sort by

+ + {Object.values(SortBy).map((sortBy) => ( +
+ + + + +
+ ))} +
+

Level

+ {Object.values(Level).map((level) => { + const active = currentLevels.includes(level as Level); + + return ( +
+ + update( + "levels", + currentLevels, + setCurrentLevels, + level, + checked as boolean + ) + } + > + + + + + +
+ ); + })} +

Kind

+ {Object.keys(filteredInstructionMethods) + .slice(0, expanded ? undefined : 5) + .map((instructionMethod) => { + const active = currentInstructionMethods.includes( + instructionMethod as InstructionMethod + ); + + return ( +
+ + update( + "components", + currentInstructionMethods, + setCurrentInstructionMethods, + instructionMethod as InstructionMethod, + checked as boolean + ) + } + > + + + + + +
+ ); + })} +
setExpanded(!expanded)}> + {expanded ? : } + {expanded ? "View less" : "View more"} +
+
+
+ ); +} diff --git a/frontend/src/components/CourseBrowser/Header/Header.module.scss b/frontend/src/components/CourseBrowser/Header/Header.module.scss new file mode 100644 index 000000000..4a5c0dd48 --- /dev/null +++ b/frontend/src/components/CourseBrowser/Header/Header.module.scss @@ -0,0 +1,42 @@ +.root { + padding: 12px; + position: sticky; + top: 0; + z-index: 1; + background: linear-gradient(to bottom, var(--background-color), transparent); + + &.overlay .group { + padding-right: 8px; + } + + .group { + border: 1px solid var(--blue-500); + border-radius: 4px; + height: 48px; + background-color: var(--foreground-color); + display: flex; + align-items: center; + gap: 16px; + padding: 0 16px; + + &:has(.input:focus) { + outline: 4px solid color-mix(in srgb, var(--blue-500) 25%, transparent); + } + + .label { + font-size: 12px; + color: var(--label-color); + line-height: 1; + } + + .input { + color: var(--heading-color); + font-size: 14px; + height: 100%; + + &::placeholder { + color: var(--paragraph-color); + } + } + } +} diff --git a/frontend/src/components/CourseBrowser/Header/index.tsx b/frontend/src/components/CourseBrowser/Header/index.tsx new file mode 100644 index 000000000..332ad3d7a --- /dev/null +++ b/frontend/src/components/CourseBrowser/Header/index.tsx @@ -0,0 +1,91 @@ +import { forwardRef } from "react"; + +import classNames from "classnames"; +import { Filter, FilterSolid } from "iconoir-react"; +import { useSearchParams } from "react-router-dom"; + +import IconButton from "@/components/IconButton"; +import { ICourse } from "@/lib/api"; + +import styles from "./Header.module.scss"; + +interface HeaderProps { + currentQuery: string; + currentCourses: ICourse[]; + open: boolean; + overlay: boolean; + onOpenChange: (open: boolean) => void; + className?: string; + autoFocus?: boolean; + setCurrentQuery: (query: string) => void; + persistent?: boolean; +} + +const Header = forwardRef( + ( + { + currentQuery, + currentCourses, + open, + overlay, + onOpenChange, + className, + autoFocus, + setCurrentQuery, + persistent, + }, + ref + ) => { + const [searchParams, setSearchParams] = useSearchParams(); + + const handleQueryChange = (value: string) => { + if (persistent) { + if (value) searchParams.set("query", value); + else searchParams.delete("query"); + setSearchParams(searchParams, { replace: true }); + + return; + } + + setCurrentQuery(value); + }; + + return ( +
+
+ handleQueryChange(event.target.value)} + placeholder={`Search courses...`} + autoFocus={autoFocus} + // TODO: onFocus could not be passed down from the parent + onFocus={() => overlay && open && onOpenChange(false)} + /> +
+ {currentCourses.length.toLocaleString()} +
+ {overlay && ( + onOpenChange(!open)}> + {open ? : } + + )} +
+
+ ); + } +); + +Header.displayName = "Header"; + +export default Header; diff --git a/frontend/src/components/CourseBrowser/List/Course/Course.module.scss b/frontend/src/components/CourseBrowser/List/Course/Course.module.scss new file mode 100644 index 000000000..911c356e7 --- /dev/null +++ b/frontend/src/components/CourseBrowser/List/Course/Course.module.scss @@ -0,0 +1,59 @@ +.root { + border: 1px solid var(--border-color); + border-radius: 8px; + flex-shrink: 0; + background-color: var(--foreground-color); + position: relative; + padding: 16px; + display: flex; + gap: 16px; + align-items: flex-start; + cursor: pointer; + + &:hover .column .icon { + color: var(--heading-color); + } + + .column { + display: flex; + flex-direction: column; + gap: 8px; + flex-shrink: 0; + + .icon { + color: var(--paragraph-color); + transition: all 100ms ease-in-out; + } + } + + .text { + flex-grow: 1; + font-size: 14px; + + .heading, .title { + color: var(--heading-color); + margin-bottom: 8px; + line-height: 1; + } + + .heading { + font-weight: 700; + } + + .title { + font-weight: 500; + } + + .description { + color: var(--paragraph-color); + line-height: 1.5; + } + + .row { + display: flex; + gap: 12px; + margin-top: 12px; + align-items: center; + } + } +} \ No newline at end of file diff --git a/frontend/src/components/CourseBrowser/List/Course/index.tsx b/frontend/src/components/CourseBrowser/List/Course/index.tsx new file mode 100644 index 000000000..2b437fb4b --- /dev/null +++ b/frontend/src/components/CourseBrowser/List/Course/index.tsx @@ -0,0 +1,43 @@ +import { MouseEventHandler, forwardRef } from "react"; + +import { ArrowRight } from "iconoir-react"; + +import AverageGrade from "@/components/AverageGrade"; +import { ICourse } from "@/lib/api"; + +import styles from "./Course.module.scss"; + +interface CourseProps { + index: number; + onClick: MouseEventHandler; +} + +const Course = forwardRef( + ({ title, subject, number, gradeAverage, index, onClick }, ref) => { + return ( +
+
+

+ {subject} {number} +

+

{title}

+
+ +
+
+
+
+ +
+
+
+ ); + } +); + +export default Course; diff --git a/frontend/src/components/CourseBrowser/List/List.module.scss b/frontend/src/components/CourseBrowser/List/List.module.scss new file mode 100644 index 000000000..4d4e8dc1e --- /dev/null +++ b/frontend/src/components/CourseBrowser/List/List.module.scss @@ -0,0 +1,88 @@ +.root { + width: 384px; + flex-shrink: 0; + overflow: auto; + background-color: var(--background-color); + + &:not(.block) { + border-right: 1px solid var(--border-color); + } + + &.block { + width: 100%; + } + + .view { + position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 100%; + + .body { + padding: 0 12px; + position: absolute; + top: 0; + left: 0; + width: 100%; + display: flex; + flex-direction: column; + gap: 12px; + } + + .placeholder { + color: var(--paragraph-color); + flex-grow: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 0 24px 12px; + gap: 16px; + font-size: 16px; + + .heading { + font-weight: 500; + color: var(--heading-color); + margin-bottom: 8px; + } + + .description { + line-height: 1.5; + } + } + + .footer { + padding: 12px; + position: sticky; + bottom: 0; + background: linear-gradient(to top, var(--background-color), transparent); + + .button { + width: 100%; + height: 48px; + display: flex; + align-items: center; + border-radius: 4px; + background-color: var(--blue-500); + color: white; + padding: 0 16px; + gap: 16px; + cursor: pointer; + transition: all 100ms ease-in-out; + + .text { + flex-grow: 1; + font-size: 14px; + font-weight: 500; + line-height: 1; + } + + &:hover { + background-color: var(--blue-600); + } + } + } + } +} \ No newline at end of file diff --git a/frontend/src/components/CourseBrowser/List/index.tsx b/frontend/src/components/CourseBrowser/List/index.tsx new file mode 100644 index 000000000..dd4566575 --- /dev/null +++ b/frontend/src/components/CourseBrowser/List/index.tsx @@ -0,0 +1,135 @@ +import { useEffect, useRef } from "react"; + +import { useVirtualizer } from "@tanstack/react-virtual"; +import classNames from "classnames"; +import { ArrowRight, FrameAltEmpty, Sparks } from "iconoir-react"; +import { Link, useSearchParams } from "react-router-dom"; + +import LoadingIndicator from "@/components/LoadingIndicator"; +import { ICourse } from "@/lib/api"; + +import Header from "../Header"; +import Course from "./Course"; +import styles from "./List.module.scss"; + +interface ListProps { + currentCourses: ICourse[]; + onSelect: (course: ICourse) => void; + onOpenChange: (open: boolean) => void; + open: boolean; + overlay: boolean; + block: boolean; + currentQuery: string; + setCurrentQuery: (query: string) => void; + persistent?: boolean; + loading: boolean; +} + +export default function List({ + currentCourses, + onSelect, + open, + overlay, + block, + onOpenChange, + currentQuery, + setCurrentQuery, + persistent, + loading, +}: ListProps) { + const rootRef = useRef(null); + const [searchParams] = useSearchParams(); + + const virtualizer = useVirtualizer({ + count: currentCourses.length, + getScrollElement: () => rootRef.current, + estimateSize: () => 136, + paddingStart: 72, + paddingEnd: 72, + gap: 12, + }); + + useEffect(() => { + rootRef.current?.scrollTo({ top: 0 }); + }, [searchParams]); + + const items = virtualizer.getVirtualItems(); + + const totalSize = virtualizer.getTotalSize(); + + return ( +
+
+
+ {loading ? ( +
+ +
+

Fetching courses...

+

+ Search for, filter, and sort courses to narrow down your + results. +

+
+
+ ) : items.length === 0 ? ( +
+ +
+

No courses found

+

+ Find courses by broadening your search or entering a different + query. +

+
+
+ ) : ( +
+ {items.map(({ key, index }) => { + const course = currentCourses[index]; + + return ( + onSelect(course)} + /> + ); + })} +
+ )} +
+ + +

Try exploring courses

+ + +
+
+
+ ); +} diff --git a/frontend/src/components/CourseBrowser/browser.ts b/frontend/src/components/CourseBrowser/browser.ts new file mode 100644 index 000000000..ba96adec7 --- /dev/null +++ b/frontend/src/components/CourseBrowser/browser.ts @@ -0,0 +1,143 @@ +import Fuse from "fuse.js"; + +import { + AcademicCareer, + ICourse, + InstructionMethod, + academicCareers, +} from "@/lib/api"; +import { subjects } from "@/lib/course"; + +export enum SortBy { + Relevance = "Relevance", + AverageGrade = "Average grade", +} + +export enum Level { + LowerDivision = "Lower Division", + UpperDivision = "Upper Division", + Graduate = "Graduate", + Extension = "Extension", +} + +export const getLevel = (academicCareer: AcademicCareer, number: string) => { + return academicCareer === AcademicCareer.Undergraduate + ? number.match(/(\d)\d\d/) + ? Level.UpperDivision + : Level.LowerDivision + : (academicCareers[academicCareer] as Level); +}; + +export const getFilteredCourses = ( + courses: ICourse[], + currentInstructionMethods: InstructionMethod[], + currentLevels: Level[] +) => { + return courses.reduce( + (acc, course) => { + // Filter by component + if ( + currentInstructionMethods.length > 0 && + !currentInstructionMethods.includes(course.primaryInstructionMethod) + ) { + acc.excludedCourses.push(course); + + return acc; + } + + // Filter by level + if (currentLevels.length > 0) { + const level = getLevel(course.academicCareer, course.number); + + if (!currentLevels.includes(level)) { + acc.excludedCourses.push(course); + + return acc; + } + } + + acc.includedCourses.push(course); + + return acc; + }, + { includedCourses: [], excludedCourses: [] } as { + includedCourses: ICourse[]; + excludedCourses: ICourse[]; + } + ); +}; + +export const initialize = (courses: ICourse[]) => { + const list = courses.map((course) => { + const { title, subject, number } = course; + + // For prefixed courses, prefer the number and add an abbreviation with the prefix + const containsPrefix = /^[a-zA-Z].*/.test(number); + const alternateNumber = number.slice(1); + + const term = subject.toLowerCase(); + + const alternateNames = subjects[term]?.abbreviations.reduce( + (acc, abbreviation) => { + // Add alternate names for abbreviations + const abbreviations = [ + `${abbreviation}${number}`, + `${abbreviation} ${number}`, + ]; + + if (containsPrefix) { + abbreviations.push( + `${abbreviation}${alternateNumber}`, + `${abbreviation} ${alternateNumber}` + ); + } + + return [...acc, ...abbreviations]; + }, + // Add alternate names + containsPrefix + ? [ + `${subject}${number}`, + `${subject} ${alternateNumber}`, + `${subject}${alternateNumber}`, + ] + : [`${subject}${number}`] + ); + + return { + title, + // subject, + // number, + name: `${subject} ${number}`, + alternateNames, + }; + }); + + // Attempt to increase performance by dropping unnecessary fields + const options = { + includeScore: true, + // ignoreLocation: true, + threshold: 0.25, + keys: [ + // { name: "number", weight: 1.2 }, + "name", + "title", + { + name: "alternateNames", + weight: 2, + }, + // { name: "subject", weight: 1.5 }, + ], + // TODO: Fuse types are wrong for sortFn + // eslint-disable-next-line @typescript-eslint/no-explicit-any + // sortFn: (a: any, b: any) => { + // // First, sort by score + // if (a.score - b.score) return a.score - b.score; + + // // Otherwise, sort by number + // return a.item[0].v.toLowerCase().localeCompare(b.item[0].v.toLowerCase()); + // }, + }; + + return new Fuse(list, options); +}; diff --git a/frontend/src/components/CourseBrowser/index.tsx b/frontend/src/components/CourseBrowser/index.tsx new file mode 100644 index 000000000..c244b2a88 --- /dev/null +++ b/frontend/src/components/CourseBrowser/index.tsx @@ -0,0 +1,179 @@ +import { useMemo, useState } from "react"; + +import { useQuery } from "@apollo/client"; +import classNames from "classnames"; +import { useSearchParams } from "react-router-dom"; + +import useWindowDimensions from "@/hooks/useWindowDimensions"; +import { + GET_COURSES, + GetCoursesResponse, + ICourse, + InstructionMethod, +} from "@/lib/api"; + +import styles from "./CourseBrowser.module.scss"; +import Filters from "./Filters"; +import List from "./List"; +import { Level, SortBy, getFilteredCourses, initialize } from "./browser"; + +interface CourseBrowserProps { + onSelect: (course: ICourse) => void; + responsive?: boolean; + persistent?: boolean; +} + +export default function CourseBrowser({ + onSelect, + responsive = true, + persistent, +}: CourseBrowserProps) { + const [open, setOpen] = useState(false); + const [searchParams] = useSearchParams(); + const { width } = useWindowDimensions(); + + const [localQuery, setLocalQuery] = useState(""); + const [localInstructionMethods, setLocalInstructionMethods] = useState< + InstructionMethod[] + >([]); + const [localLevels, setLocalLevels] = useState([]); + const [localSortBy, setLocalSortBy] = useState(SortBy.Relevance); + + const block = useMemo(() => width <= 992, [width]); + + const overlay = useMemo( + () => (responsive && width <= 1400) || block, + [width, responsive, block] + ); + + const { data, loading } = useQuery(GET_COURSES); + + const courses = useMemo(() => data?.courseList ?? [], [data?.courseList]); + + const currentQuery = useMemo( + () => (persistent ? searchParams.get("query") ?? "" : localQuery), + [searchParams, localQuery, persistent] + ); + + const currentInstructionMethods = useMemo( + () => + persistent + ? ((searchParams + .get("instruction-methods") + ?.split(",") + .filter((instructionMethod) => + Object.values(InstructionMethod).includes( + instructionMethod as InstructionMethod + ) + ) ?? []) as InstructionMethod[]) + : localInstructionMethods, + [searchParams, localInstructionMethods, persistent] + ); + + const currentLevels = useMemo( + () => + persistent + ? ((searchParams + .get("levels") + ?.split(",") + .filter((level) => Object.values(Level).includes(level as Level)) ?? + []) as Level[]) + : localLevels, + [searchParams, localLevels, persistent] + ); + + const currentSortBy = useMemo(() => { + if (persistent) { + const parameter = searchParams.get("sortBy") as SortBy; + + return Object.values(SortBy).includes(parameter) + ? parameter + : SortBy.Relevance; + } + + return localSortBy; + }, [searchParams, localSortBy, persistent]); + + const { includedCourses, excludedCourses } = useMemo( + () => getFilteredCourses(courses, currentInstructionMethods, currentLevels), + [courses, currentInstructionMethods, currentLevels] + ); + + const index = useMemo(() => initialize(includedCourses), [includedCourses]); + + const currentCourses = useMemo(() => { + let filteredClasses = currentQuery + ? index + // Limit query because Fuse performance decreases linearly by + // n (field length) * m (pattern length) * l (maximum Levenshtein distance) + .search(currentQuery.slice(0, 24)) + .map(({ refIndex }) => includedCourses[refIndex]) + : includedCourses; + + if (currentSortBy) { + // Clone the courses to avoid sorting in-place + filteredClasses = structuredClone(filteredClasses).sort((a, b) => { + if (currentSortBy === SortBy.AverageGrade) { + return b.gradeAverage === a.gradeAverage + ? 0 + : b.gradeAverage === null + ? -1 + : a.gradeAverage === null + ? 1 + : b.gradeAverage - a.gradeAverage; + } + + // Classes are by default sorted by relevance and number + return 0; + }); + } + + return filteredClasses; + }, [currentQuery, index, includedCourses, currentSortBy]); + + return ( +
+ {(open || !overlay) && ( + + )} + {(!open || !overlay) && ( + + )} +
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1ddb54d80..03b1a3125 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -7,6 +7,31 @@ export enum Semester { Winter = "Winter", } +export enum InstructionMethod { + Unknown = "UNK", + DirectedGroupStudy = "GRP", + Workshop = "WOR", + WebBasedDiscussion = "WBD", + Tutorial = "TUT", + Seminar = "SEM", + FieldWork = "FLD", + Recitation = "REC", + IndependentStudy = "IND", + Session = "SES", + Colloquium = "COL", + Clinic = "CLC", + Studio = "STD", + Lecture = "LEC", + Reading = "REA", + Internship = "INT", + Discussion = "DIS", + Demonstration = "DEM", + Conversation = "CON", + SelfPaced = "SLF", + WebBasedLecture = "WBL", + Laboratory = "LAB", +} + export enum Component { Workshop = "WOR", WebBasedDiscussion = "WBD", @@ -32,6 +57,31 @@ export enum Component { Seminar = "SEM", } +export const instructionMethods: Record = { + [InstructionMethod.Lecture]: "Lecture", + [InstructionMethod.Seminar]: "Seminar", + [InstructionMethod.IndependentStudy]: "Independent Study", + [InstructionMethod.DirectedGroupStudy]: "Directed Group Study", + [InstructionMethod.Studio]: "Studio", + [InstructionMethod.Laboratory]: "Laboratory", + [InstructionMethod.Workshop]: "Workshop", + [InstructionMethod.WebBasedDiscussion]: "Web-Based Discussion", + [InstructionMethod.Clinic]: "Clinic", + [InstructionMethod.Discussion]: "Discussion", + [InstructionMethod.Tutorial]: "Tutorial", + [InstructionMethod.FieldWork]: "Field Work", + [InstructionMethod.Session]: "Session", + [InstructionMethod.SelfPaced]: "Self-paced", + [InstructionMethod.Colloquium]: "Colloquium", + [InstructionMethod.WebBasedLecture]: "Web-Based Lecture", + [InstructionMethod.Internship]: "Internship", + [InstructionMethod.Reading]: "Reading", + [InstructionMethod.Recitation]: "Recitation", + [InstructionMethod.Unknown]: "Unknown", + [InstructionMethod.Demonstration]: "Demonstration", + [InstructionMethod.Conversation]: "Conversation", +}; + export const components: Record = { [Component.Lecture]: "Lecture", [Component.Seminar]: "Seminar", @@ -146,7 +196,7 @@ export interface ICourse { sections: ISection[]; requiredCourses: ICourse[]; requirements: string | null; - primaryComponent: Component; + primaryInstructionMethod: InstructionMethod; description: string; fromDate: string; gradeAverage: number | null; @@ -157,6 +207,7 @@ export interface ICourse { subject: string; number: string; toDate: string; + typicallyOffered: Semester[] | null; } export interface IAccount { @@ -324,10 +375,8 @@ export const GET_COURSES = gql` academicCareer finalExam gradingBasis - classes { - year - semester - } + typicallyOffered + primaryInstructionMethod } } `;