From 124ac0b60ab50ac4828240e0c217584b6e51bf6c Mon Sep 17 00:00:00 2001 From: Xavilien Date: Sun, 17 Mar 2024 08:38:03 -0400 Subject: [PATCH] Instructor Search (#137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * MVP for professor search * Only change the page if page is not already the first * Use constant for number of results per page * Made a little efficiency improvement for those who hold down backspace when deleting * Used debouncer to fix laggy typing * Fixed bug that prevents aggregate filters from having any effect * Fixed an annoying UI issue where there is a loading bar for every single professor entry by hiding loading for professors list but showing it for individual pages * Refactored professors to instructors * Use fuse for fuzzy search for instructors * Remove unused variable * Added missing dispatch dependency * Refactored some code so that search is speedier * Changed getAllInstructors to actually get all instructors rather than just professors * Set the Fuse search to be a global variable in cache.ts * Set the Fuse search to be a global variable in cache.ts * Changed type of fuseIndex --------- Co-authored-by: “xavilien” <“xavilien@gmail.com”> --- backend/src/app.ts | 3 ++ backend/src/controllers/instructors.mts | 38 +++++++++++++ frontend/package-lock.json | 14 +++++ frontend/package.json | 1 + frontend/src/app/api/instructors.ts | 24 +++++++++ frontend/src/app/cache.ts | 41 ++++++++++++++ frontend/src/app/instructors.ts | 21 ++++++++ frontend/src/app/store.ts | 23 ++++++++ frontend/src/components/InstructorDetail.tsx | 10 ++-- .../src/components/InstructorFCEDetail.tsx | 10 ++-- frontend/src/components/InstructorSearch.tsx | 44 +++++++++++++++ .../src/components/InstructorSearchList.tsx | 54 +++++++++++++++++++ frontend/src/components/SideNav.tsx | 7 +++ frontend/src/pages/instructor/[name].tsx | 2 +- frontend/src/pages/instructors.tsx | 30 +++++++++++ 15 files changed, 312 insertions(+), 10 deletions(-) create mode 100644 backend/src/controllers/instructors.mts create mode 100644 frontend/src/app/api/instructors.ts create mode 100644 frontend/src/app/instructors.ts create mode 100644 frontend/src/components/InstructorSearch.tsx create mode 100644 frontend/src/components/InstructorSearchList.tsx create mode 100644 frontend/src/pages/instructors.tsx diff --git a/backend/src/app.ts b/backend/src/app.ts index 29ac801..0d4b1da 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -6,6 +6,7 @@ import { generateSigningRequestHandler, KeyStore } from "passlink-server"; import { isUser } from "./controllers/user.mjs"; import { getAllCourses, getCourseByID, getCourses, getFilteredCourses } from "./controllers/courses.mjs"; import { getFCEs } from "./controllers/fces.mjs"; +import { getInstructors } from "./controllers/instructors.mjs"; // because there is a bug in the typing for passlink // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -44,6 +45,8 @@ app.route("/courses/search/").post(isUser, getFilteredCourses); app.route("/fces").post(isUser, getFCEs); +app.route("/instructors").get(getInstructors); + // the next parameter is needed! // eslint-disable-next-line @typescript-eslint/no-unused-vars const errorHandler: ErrorRequestHandler = (err, req, res, next) => { diff --git a/backend/src/controllers/instructors.mts b/backend/src/controllers/instructors.mts new file mode 100644 index 0000000..4325c26 --- /dev/null +++ b/backend/src/controllers/instructors.mts @@ -0,0 +1,38 @@ +import { RequestHandler } from "express"; +import { PrismaReturn } from "../util.mjs"; +import prisma from "../models/prisma.mjs"; + +const getAllInstructorsDbQuery = { + select: { + instructor: true, + }, +}; + +export interface GetInstructors { + params: unknown; + resBody: PrismaReturn>; + reqBody: unknown; + query: unknown; +} + +export const getInstructors: RequestHandler< + GetInstructors["params"], + GetInstructors["resBody"], + GetInstructors["reqBody"], + GetInstructors["query"] +> = async (_, res, next) => { + try { + const instructors = await prisma.fces.findMany({ + select: { + instructor: true, + }, + orderBy: { + instructor: "asc", + }, + distinct: ["instructor"] + }); + res.json(instructors); + } catch (e) { + next(e); + } +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d18ae9c..bc3baf5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@types/uuid": "^8.3.4", "axios": "^0.24.0", "downshift": "^6.1.7", + "fuse.js": "^7.0.0", "jose": "^4.8.1", "namecase": "^1.1.2", "next": "^13.0.6", @@ -6211,6 +6212,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz", + "integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==", + "engines": { + "node": ">=10" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -16196,6 +16205,11 @@ "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true }, + "fuse.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.0.0.tgz", + "integrity": "sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==" + }, "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0d9222c..23f9423 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "@types/uuid": "^8.3.4", "axios": "^0.24.0", "downshift": "^6.1.7", + "fuse.js": "^7.0.0", "jose": "^4.8.1", "namecase": "^1.1.2", "next": "^13.0.6", diff --git a/frontend/src/app/api/instructors.ts b/frontend/src/app/api/instructors.ts new file mode 100644 index 0000000..5c0d245 --- /dev/null +++ b/frontend/src/app/api/instructors.ts @@ -0,0 +1,24 @@ +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { RootState } from "../store"; + +type FetchAllInstructorsType = { instructor: string }[]; + +export const fetchAllInstructors = createAsyncThunk< + FetchAllInstructorsType, + void, + { state: RootState } +>("fetchAllInstructors", async (_, thunkAPI) => { + const url = `${process.env.backendUrl}/instructors`; + const state = thunkAPI.getState(); + + if (state.cache.allInstructors.length > 0) return; + + return ( + await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }) + ).json(); +}); diff --git a/frontend/src/app/cache.ts b/frontend/src/app/cache.ts index 9c5b14a..34c8cd1 100644 --- a/frontend/src/app/cache.ts +++ b/frontend/src/app/cache.ts @@ -9,6 +9,8 @@ import { FetchCourseInfosByPageResult, } from "./api/course"; import { fetchFCEInfosByCourse, fetchFCEInfosByInstructor } from "./api/fce"; +import { fetchAllInstructors } from "./api/instructors"; +import Fuse, { FuseIndex } from "fuse.js"; /** * This cache lasts for the duration of the user session @@ -29,6 +31,11 @@ interface CacheState { coursesLoading: boolean; exactResultsCourses: string[]; allCourses: { courseID: string; name: string }[]; + allInstructors: { instructor: string }[]; + fuseIndex: { [key: string]: any }; + instructorsLoading: boolean; + instructorPage: number; + selectedInstructors: { instructor: string }[]; } const initialState: CacheState = { @@ -45,6 +52,11 @@ const initialState: CacheState = { coursesLoading: false, exactResultsCourses: [], allCourses: [], + allInstructors: [], + fuseIndex: {}, + instructorsLoading: false, + instructorPage: 1, + selectedInstructors: [], }; export const selectCourseResults = @@ -92,6 +104,22 @@ export const cacheSlice = createSlice({ setCoursesLoading: (state, action: PayloadAction) => { state.coursesLoading = action.payload; }, + setInstructorPage: (state, action: PayloadAction) => { + state.instructorPage = action.payload; + }, + setInstructorsLoading: (state, action: PayloadAction) => { + state.instructorsLoading = action.payload; + }, + selectInstructors: (state, action: PayloadAction) => { + const search = action.payload + if (!search) { + state.selectedInstructors = state.allInstructors; + return; + } + const fuseIndex : FuseIndex<{ instructor: string }> = Fuse.parseIndex(state.fuseIndex); + const fuse = new Fuse(state.allInstructors, {}, fuseIndex); + state.selectedInstructors = fuse.search(search).map(({item}) => item); + } }, extraReducers: (builder) => { builder @@ -187,6 +215,19 @@ export const cacheSlice = createSlice({ state.instructorResults[action.meta.arg] = action.payload; }); + + builder + .addCase(fetchAllInstructors.pending, (state) => { + state.instructorsLoading = true; + }) + .addCase(fetchAllInstructors.fulfilled, (state, action) => { + state.instructorsLoading = false; + if (action.payload) { + state.allInstructors = action.payload; + state.fuseIndex = Fuse.createIndex(["instructor"], action.payload).toJSON(); + state.selectedInstructors = action.payload; + } + }); }, }); diff --git a/frontend/src/app/instructors.ts b/frontend/src/app/instructors.ts new file mode 100644 index 0000000..85e765a --- /dev/null +++ b/frontend/src/app/instructors.ts @@ -0,0 +1,21 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +export interface InstructorsState { + search: string; +} + +const initialState: InstructorsState = { + search: "", +}; + +export const instructorsSlice = createSlice({ + name: "instructors", + initialState, + reducers: { + updateSearch: (state, action: PayloadAction) => { + state.search = action.payload; + }, + }, +}); + +export const reducer = instructorsSlice.reducer; diff --git a/frontend/src/app/store.ts b/frontend/src/app/store.ts index fb38abd..6d1aef8 100644 --- a/frontend/src/app/store.ts +++ b/frontend/src/app/store.ts @@ -9,6 +9,7 @@ import { UserSchedulesState, } from "./userSchedules"; import { reducer as uiReducer, UIState } from "./ui"; +import { reducer as instructorsReducer, InstructorsState } from "./instructors"; import debounce from "lodash/debounce"; import { FLUSH, @@ -62,6 +63,15 @@ const reducers = combineReducers({ }, uiReducer ), + instructors: persistReducer( + { + key: "instructors", + version: 1, + storage, + stateReconciler: autoMergeLevel2, + }, + instructorsReducer + ), }); export const store = configureStore({ @@ -96,6 +106,19 @@ export const throttledFilter = () => { debouncedFilter(); }; +const debouncedInstructorFilter = debounce(() => { + setTimeout(() => { + const state = store.getState(); + void store.dispatch(cacheSlice.actions.selectInstructors(state.instructors.search)); + void store.dispatch(cacheSlice.actions.setInstructorsLoading(false)); + }, 0); +}, 300); + +export const throttledInstructorFilter = () => { + void store.dispatch(cacheSlice.actions.setInstructorsLoading(true)); + debouncedInstructorFilter(); +} + export type AppState = ReturnType; export type AppDispatch = typeof store.dispatch; diff --git a/frontend/src/components/InstructorDetail.tsx b/frontend/src/components/InstructorDetail.tsx index 9e1ef08..7f56e38 100644 --- a/frontend/src/components/InstructorDetail.tsx +++ b/frontend/src/components/InstructorDetail.tsx @@ -9,14 +9,16 @@ import { Card } from "./Card"; type Props = { name: string; + showLoading: boolean; }; -const InstructorDetail = ({ name }: Props) => { +const InstructorDetail = ({ name, showLoading }: Props) => { const dispatch = useAppDispatch(); const loggedIn = useAppSelector((state) => state.user.loggedIn); const fces = useAppSelector(selectFCEResultsForInstructor(name)); - console.log(fces); + + const aggregationOptions = useAppSelector((state) => state.user.fceAggregation); useEffect(() => { if (name) void dispatch(fetchFCEInfosByInstructor(name)); @@ -24,7 +26,7 @@ const InstructorDetail = ({ name }: Props) => { if (!fces) { return ( -
+
); @@ -42,7 +44,7 @@ const InstructorDetail = ({ name }: Props) => { {/* TODO: Add more information about instructor using Directory API */}
- +
diff --git a/frontend/src/components/InstructorFCEDetail.tsx b/frontend/src/components/InstructorFCEDetail.tsx index 7d6aa8c..79e8a9d 100644 --- a/frontend/src/components/InstructorFCEDetail.tsx +++ b/frontend/src/components/InstructorFCEDetail.tsx @@ -1,12 +1,12 @@ import React from "react"; import { FCE } from "../app/types"; import { FCETable } from "./FCETable"; +import { AggregateFCEsOptions } from "../app/fce"; -export const InstructorFCEDetail = ({ fces }: { fces: FCE[] }) => { - const aggregationOptions = { - numSemesters: 10, - counted: { spring: true, summer: true, fall: true }, - }; +export const InstructorFCEDetail = ({ fces, aggregationOptions }: { + fces: FCE[], + aggregationOptions: AggregateFCEsOptions +}) => { return ( { + const dispatch = useAppDispatch(); + const page = useAppSelector((state) => state.cache.instructorPage); + const search = useAppSelector((state) => state.instructors.search); + + const onChange = (e: React.ChangeEvent) => { + dispatch(instructorsSlice.actions.updateSearch(e.target.value)); + if (page !== 1) dispatch(cacheSlice.actions.setInstructorPage(1)); + throttledInstructorFilter(); + }; + + const results = useAppSelector((state) => state.cache.selectedInstructors); + const numResults = results.length; + + return ( + <> +
+ + + + +
+
+
{numResults} results
+
+ + ); +}; + +export default InstructorSearch; diff --git a/frontend/src/components/InstructorSearchList.tsx b/frontend/src/components/InstructorSearchList.tsx new file mode 100644 index 0000000..18754d0 --- /dev/null +++ b/frontend/src/components/InstructorSearchList.tsx @@ -0,0 +1,54 @@ +import { useAppDispatch, useAppSelector } from "../app/hooks"; +import Loading from "./Loading"; +import { Pagination } from "./Pagination"; +import React, { useEffect } from "react"; +import InstructorDetail from "./InstructorDetail"; +import { fetchAllInstructors } from "../app/api/instructors"; +import { cacheSlice } from "../app/cache"; + +const RESULTS_PER_PAGE = 10; + +const InstructorSearchList = () => { + const dispatch = useAppDispatch(); + + useEffect(() => { + void dispatch(fetchAllInstructors()); + }, [dispatch]); + + const results = useAppSelector((state) => state.cache.selectedInstructors); + const pages = Math.ceil(results.length / RESULTS_PER_PAGE); + const curPage = useAppSelector((state) => state.cache.instructorPage); + const loading = useAppSelector((state) => state.cache.instructorsLoading); + + const handlePageClick = (page: number) => { + dispatch(cacheSlice.actions.setInstructorPage(page + 1)); + }; + + return ( +
+ {loading ? ( + + ) : ( + <> +
+ {results && + results + .slice(curPage * RESULTS_PER_PAGE - RESULTS_PER_PAGE, curPage * RESULTS_PER_PAGE) + .map((instructor) => ( + + ))} +
+
+ +
+ + )} +
+ ); +}; + +export default InstructorSearchList; diff --git a/frontend/src/components/SideNav.tsx b/frontend/src/components/SideNav.tsx index e10b6d0..3c6dcd2 100644 --- a/frontend/src/components/SideNav.tsx +++ b/frontend/src/components/SideNav.tsx @@ -3,6 +3,7 @@ import { ClockIcon, MagnifyingGlassIcon, StarIcon, + UserCircleIcon, } from "@heroicons/react/24/outline"; import React from "react"; import Link from "next/link"; @@ -80,6 +81,12 @@ export const SideNav = ({ activePage }) => { newTab active={false} /> + ); }; diff --git a/frontend/src/pages/instructor/[name].tsx b/frontend/src/pages/instructor/[name].tsx index 93a40bb..1cb4281 100644 --- a/frontend/src/pages/instructor/[name].tsx +++ b/frontend/src/pages/instructor/[name].tsx @@ -10,7 +10,7 @@ const InstructorPage: NextPage = () => { const name = router.query.name as string; return ( - } sidebar={} /> + } sidebar={} /> ); }; diff --git a/frontend/src/pages/instructors.tsx b/frontend/src/pages/instructors.tsx new file mode 100644 index 0000000..e862980 --- /dev/null +++ b/frontend/src/pages/instructors.tsx @@ -0,0 +1,30 @@ +import type { NextPage } from "next"; +import Topbar from "../components/Topbar"; +import InstructorSearch from "../components/InstructorSearch"; +import InstructorSearchList from "../components/InstructorSearchList"; +import React from "react"; +import { Page } from "../components/Page"; +import Aggregate from "../components/Aggregate"; + +const InstructorsPage: NextPage = () => { + return ( + + + + } + content={ + <> + + + + + + } + activePage="instructors" + /> + ); +}; + +export default InstructorsPage;