diff --git a/frontend/src/app/fce.ts b/frontend/src/app/fce.ts index 60d70d0..4af35f8 100644 --- a/frontend/src/app/fce.ts +++ b/frontend/src/app/fce.ts @@ -49,7 +49,11 @@ export interface AggregateFCEsOptions { summer: boolean; fall: boolean; }; - courses?: string[]; + filters: { + type: string; + courses: string[]; + instructors: string[]; + } numSemesters: number; } @@ -66,9 +70,15 @@ export const filterFCEs = (fces: FCE[], options: AggregateFCEsOptions) => { result.push(fce); } - if (options.courses) { + if (options.filters.type === "courses" && options.filters.courses) { result = result.filter(({ courseID }) => - options.courses.includes(courseID) + options.filters.courses.includes(courseID) + ); + } + + if (options.filters.type === "instructors" && options.filters.instructors) { + result = result.filter(({ instructor }) => + options.filters.instructors.includes(instructor) ); } @@ -101,7 +111,7 @@ export const aggregateCourses = ( })); const coursesWithoutFilteredFCEs = aggregatedFCEs - .filter(({ courseID, aggregateData }) => aggregateData.fcesCounted === 0) + .filter(({ aggregateData }) => aggregateData.fcesCounted === 0) .map(({ courseID }) => courseID); if (coursesWithoutFilteredFCEs.length > 0) { diff --git a/frontend/src/app/user.ts b/frontend/src/app/user.ts index a6496a4..601dede 100644 --- a/frontend/src/app/user.ts +++ b/frontend/src/app/user.ts @@ -15,6 +15,11 @@ export interface UserState { summer: boolean; fall: boolean; }; + filters: { + type: string; + courses: string[]; + instructors: string[]; + } }; token: string; } @@ -31,6 +36,11 @@ const initialState: UserState = { summer: false, fall: true, }, + filters: { + type: "", + courses: [], + instructors: [], + }, }, token: null, }; @@ -77,6 +87,16 @@ export const userSlice = createSlice({ setToken: (state, action: PayloadAction) => { state.token = action.payload; }, + setFilters: (state, action: PayloadAction) => { + state.fceAggregation.filters = action.payload; + }, + resetFilters: (state) => { + state.fceAggregation.filters = { + type: "", + courses: [], + instructors: [], + }; + } }, }); diff --git a/frontend/src/app/utils.tsx b/frontend/src/app/utils.tsx index d0a39be..190d2ea 100644 --- a/frontend/src/app/utils.tsx +++ b/frontend/src/app/utils.tsx @@ -209,3 +209,7 @@ export function toNameCase(name: string): string { // eslint-disable-next-line @typescript-eslint/no-unsafe-call return namecase(name) as string; } + +export function getUnique(arr: T[]): T[] { + return Array.from(new Set(arr)); +} diff --git a/frontend/src/components/CourseSearchList.tsx b/frontend/src/components/CourseSearchList.tsx index b4bf5a8..663fec2 100644 --- a/frontend/src/components/CourseSearchList.tsx +++ b/frontend/src/components/CourseSearchList.tsx @@ -6,6 +6,7 @@ import Loading from "./Loading"; import { fetchCourseInfosByPage } from "../app/api/course"; import { fetchFCEInfosByCourse } from "../app/api/fce"; import { Pagination } from "./Pagination"; +import { userSlice } from "../app/user"; const CoursePage = () => { const dispatch = useAppDispatch(); @@ -67,6 +68,8 @@ const CourseSearchList = () => { const loading = useAppSelector((state) => state.cache.coursesLoading); const dispatch = useAppDispatch(); + dispatch(userSlice.actions.resetFilters()); // Not ideal + const handlePageClick = (page: number) => { void dispatch(fetchCourseInfosByPage(page + 1)); }; diff --git a/frontend/src/components/InstructorDetail.tsx b/frontend/src/components/InstructorDetail.tsx index 7f56e38..502eb27 100644 --- a/frontend/src/components/InstructorDetail.tsx +++ b/frontend/src/components/InstructorDetail.tsx @@ -6,6 +6,7 @@ import Loading from "./Loading"; import { InstructorFCEDetail } from "./InstructorFCEDetail"; import { toNameCase } from "../app/utils"; import { Card } from "./Card"; +import Link from "next/link"; type Props = { name: string; @@ -38,9 +39,11 @@ const InstructorDetail = ({ name, showLoading }: Props) => {
-
- {toNameCase(name)} -
+ +
+ {toNameCase(name)} +
+ {/* TODO: Add more information about instructor using Directory API */}
diff --git a/frontend/src/components/InstructorSearchList.tsx b/frontend/src/components/InstructorSearchList.tsx index 18754d0..5672e70 100644 --- a/frontend/src/components/InstructorSearchList.tsx +++ b/frontend/src/components/InstructorSearchList.tsx @@ -5,6 +5,7 @@ import React, { useEffect } from "react"; import InstructorDetail from "./InstructorDetail"; import { fetchAllInstructors } from "../app/api/instructors"; import { cacheSlice } from "../app/cache"; +import { userSlice } from "../app/user"; const RESULTS_PER_PAGE = 10; @@ -20,6 +21,8 @@ const InstructorSearchList = () => { const curPage = useAppSelector((state) => state.cache.instructorPage); const loading = useAppSelector((state) => state.cache.instructorsLoading); + dispatch(userSlice.actions.resetFilters()); // Not ideal + const handlePageClick = (page: number) => { dispatch(cacheSlice.actions.setInstructorPage(page + 1)); }; diff --git a/frontend/src/components/SideNav.tsx b/frontend/src/components/SideNav.tsx index 3c6dcd2..5a6ef6d 100644 --- a/frontend/src/components/SideNav.tsx +++ b/frontend/src/components/SideNav.tsx @@ -74,6 +74,12 @@ export const SideNav = ({ activePage }) => { link="/schedules" active={activePage === "schedules"} /> + { newTab active={false} /> -
); }; diff --git a/frontend/src/components/filters/CourseFilter.tsx b/frontend/src/components/filters/CourseFilter.tsx new file mode 100644 index 0000000..6930afa --- /dev/null +++ b/frontend/src/components/filters/CourseFilter.tsx @@ -0,0 +1,144 @@ +import React, {useEffect, useState} from "react"; +import { ChevronUpDownIcon } from "@heroicons/react/24/outline"; +import { CheckIcon } from "@heroicons/react/20/solid"; +import { useAppDispatch, useAppSelector } from "../../app/hooks"; +import { Combobox } from "@headlessui/react"; +import { classNames, getUnique } from "../../app/utils"; +import { userSlice } from "../../app/user"; +import { selectFCEResultsForInstructor } from "../../app/cache"; + +type Props = { + name: string; +}; + +const CourseFilter = ({ name } : Props) => { + const dispatch = useAppDispatch(); + + const [ query, setQuery ] = useState("") + + const filteredCourses = useAppSelector((state) => state.user.fceAggregation.filters.courses); + const fces = useAppSelector(selectFCEResultsForInstructor(name)); + const courses = getUnique(fces?.map((fce) => fce.courseID).sort()); + + useEffect(() => { + dispatch(userSlice.actions.setFilters({ type: "courses", + courses: getUnique(fces?.map((fce) => fce.courseID).sort()), instructors: [] })); + }, [fces, dispatch]); + + const setCourses = (courses: string[]) => { + dispatch(userSlice.actions.setFilters({ type: "courses", courses: courses, instructors: [] })); + }; + + const searchCourses = (course: string) => { + const searchTerm = query.toLowerCase(); + return course.toLowerCase().includes(searchTerm); + } + + const noSelectAll = () => { + return filteredCourses?.filter((x) => x !== "SELECT ALL") + } + + const toggleAllCourses = () => { + if (noSelectAll().length >= courses?.length) { + setCourses([]) + } else { + setCourses(courses); + } + } + + return ( +
+ +
Courses
+ + + + {noSelectAll().length} selected + + setQuery(e.target.value)} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === " ") { + setQuery(query + " science"); + } else if (e.key === "Tab") { + const course = courses?.filter(searchCourses)[0]; + if (!filteredCourses.includes(course)) { + setCourses(filteredCourses.concat([course])); + } + } + }} + onKeyUp={(e: React.KeyboardEvent) => { + if (e.key === " ") { + e.preventDefault(); + } + }} + /> + + + + + +
+ + + + + + SELECT ALL + + + {noSelectAll().length === courses?.length && ( + + + + )} + + + {courses?.filter(searchCourses).map((name) => ( + + {({selected}) => ( + <> + + + {name} + + + {selected && ( + + + + )} + + )} + + ))} + +
+
+
+ ); +}; + +export default CourseFilter; diff --git a/frontend/src/components/filters/InstructorFilter.tsx b/frontend/src/components/filters/InstructorFilter.tsx new file mode 100644 index 0000000..5769d67 --- /dev/null +++ b/frontend/src/components/filters/InstructorFilter.tsx @@ -0,0 +1,143 @@ +import React, {useEffect, useState} from "react"; +import { ChevronUpDownIcon } from "@heroicons/react/24/outline"; +import { CheckIcon } from "@heroicons/react/20/solid"; +import { useAppDispatch, useAppSelector } from "../../app/hooks"; +import { Combobox } from "@headlessui/react"; +import { classNames, getUnique } from "../../app/utils"; +import { userSlice } from "../../app/user"; + +type Props = { + courseID: string; +}; + +const InstructorFilter = ({ courseID } : Props) => { + const dispatch = useAppDispatch(); + + const [ query, setQuery ] = useState("") + + const filteredInstructors = useAppSelector((state) => state.user.fceAggregation.filters.instructors); + const fces = useAppSelector((state) => state.cache.fces[courseID]); + const instructors = getUnique(fces?.map((fce) => fce.instructor).sort()); + + useEffect(() => { + dispatch(userSlice.actions.setFilters({ type: "instructors", courses: [], + instructors: getUnique(fces?.map((fce) => fce.instructor).sort()) })); + }, [fces, dispatch]); + + const setInstructors = (instructors: string[]) => { + dispatch(userSlice.actions.setFilters({ type: "instructors", courses: [], instructors: instructors })); + }; + + const searchInstructors = (instructor: string) => { + const searchTerm = query.toLowerCase(); + return instructor.toLowerCase().includes(searchTerm); + } + + const noSelectAll = () => { + return filteredInstructors?.filter((x) => x !== "SELECT ALL") + } + + const toggleAllInstructors = () => { + if (noSelectAll().length >= instructors?.length) { + setInstructors([]) + } else { + setInstructors(instructors); + } + } + + return ( +
+ +
Instructors
+ + + + {noSelectAll().length} selected + + setQuery(e.target.value)} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === " ") { + setQuery(query + " science"); + } else if (e.key === "Tab") { + const instructor = instructors?.filter(searchInstructors)[0]; + if (!filteredInstructors.includes(instructor)) { + setInstructors(filteredInstructors.concat([instructor])); + } + } + }} + onKeyUp={(e: React.KeyboardEvent) => { + if (e.key === " ") { + e.preventDefault(); + } + }} + /> + + + + + +
+ + + + + + SELECT ALL + + + {noSelectAll().length === instructors?.length && ( + + + + )} + + + {instructors?.filter(searchInstructors).map((name) => ( + + {({selected}) => ( + <> + + + {name} + + + {selected && ( + + + + )} + + )} + + ))} + +
+
+
+ ); +}; + +export default InstructorFilter; diff --git a/frontend/src/pages/course/[courseID].tsx b/frontend/src/pages/course/[courseID].tsx index d0ffbc5..0c209bd 100644 --- a/frontend/src/pages/course/[courseID].tsx +++ b/frontend/src/pages/course/[courseID].tsx @@ -8,6 +8,7 @@ import Loading from "../../components/Loading"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { Page } from "../../components/Page"; import { fetchCourseInfo } from "../../app/api/course"; +import InstructorFilter from "../../components/filters/InstructorFilter"; const CourseDetailPage: NextPage = () => { const router = useRouter(); @@ -31,7 +32,16 @@ const CourseDetailPage: NextPage = () => { content = ; } - return } />; + return ( + + + + } + /> + ); }; export default CourseDetailPage; diff --git a/frontend/src/pages/instructor/[name].tsx b/frontend/src/pages/instructor/[name].tsx index 1cb4281..adfc943 100644 --- a/frontend/src/pages/instructor/[name].tsx +++ b/frontend/src/pages/instructor/[name].tsx @@ -4,13 +4,21 @@ import { useRouter } from "next/router"; import { Page } from "../../components/Page"; import InstructorDetail from "../../components/InstructorDetail"; import Aggregate from "../../components/Aggregate"; +import CourseFilter from "../../components/filters/CourseFilter"; const InstructorPage: NextPage = () => { const router = useRouter(); const name = router.query.name as string; return ( - } sidebar={} /> + } + sidebar={ + <> + + + } + /> ); };