Skip to content

Commit

Permalink
Instructor Search (#137)
Browse files Browse the repository at this point in the history
* 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” <“[email protected]”>
  • Loading branch information
Xavilien and “xavilien” authored Mar 17, 2024
1 parent af8606c commit 124ac0b
Show file tree
Hide file tree
Showing 15 changed files with 312 additions and 10 deletions.
3 changes: 3 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down
38 changes: 38 additions & 0 deletions backend/src/controllers/instructors.mts
Original file line number Diff line number Diff line change
@@ -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<typeof prisma.fces.findMany<typeof getAllInstructorsDbQuery>>;
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);
}
};
14 changes: 14 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/app/api/instructors.ts
Original file line number Diff line number Diff line change
@@ -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();
});
41 changes: 41 additions & 0 deletions frontend/src/app/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {
Expand All @@ -45,6 +52,11 @@ const initialState: CacheState = {
coursesLoading: false,
exactResultsCourses: [],
allCourses: [],
allInstructors: [],
fuseIndex: {},
instructorsLoading: false,
instructorPage: 1,
selectedInstructors: [],
};

export const selectCourseResults =
Expand Down Expand Up @@ -92,6 +104,22 @@ export const cacheSlice = createSlice({
setCoursesLoading: (state, action: PayloadAction<boolean>) => {
state.coursesLoading = action.payload;
},
setInstructorPage: (state, action: PayloadAction<number>) => {
state.instructorPage = action.payload;
},
setInstructorsLoading: (state, action: PayloadAction<boolean>) => {
state.instructorsLoading = action.payload;
},
selectInstructors: (state, action: PayloadAction<string>) => {
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
Expand Down Expand Up @@ -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;
}
});
},
});

Expand Down
21 changes: 21 additions & 0 deletions frontend/src/app/instructors.ts
Original file line number Diff line number Diff line change
@@ -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<string>) => {
state.search = action.payload;
},
},
});

export const reducer = instructorsSlice.reducer;
23 changes: 23 additions & 0 deletions frontend/src/app/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -62,6 +63,15 @@ const reducers = combineReducers({
},
uiReducer
),
instructors: persistReducer<InstructorsState>(
{
key: "instructors",
version: 1,
storage,
stateReconciler: autoMergeLevel2,
},
instructorsReducer
),
});

export const store = configureStore({
Expand Down Expand Up @@ -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<typeof store.getState>;

export type AppDispatch = typeof store.dispatch;
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/components/InstructorDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,24 @@ 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));
}, [dispatch, loggedIn, name]);

if (!fces) {
return (
<div className="m-auto space-y-4 p-6">
<div className={showLoading ? "m-auto space-y-4 p-6" : "m-auto space-y-4 p-6 hidden"}>
<Loading />
</div>
);
Expand All @@ -42,7 +44,7 @@ const InstructorDetail = ({ name }: Props) => {
{/* TODO: Add more information about instructor using Directory API */}
</div>
<div>
<InstructorFCEDetail fces={fces} />
<InstructorFCEDetail fces={fces} aggregationOptions={aggregationOptions} />
</div>
</Card>
</div>
Expand Down
10 changes: 5 additions & 5 deletions frontend/src/components/InstructorFCEDetail.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<FCETable
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/components/InstructorSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { MagnifyingGlassIcon } from "@heroicons/react/24/solid";
import React from "react";
import { useAppDispatch, useAppSelector } from "../app/hooks";
import { instructorsSlice } from "../app/instructors";
import { cacheSlice } from "../app/cache";
import { throttledInstructorFilter } from "../app/store";

const InstructorSearch = () => {
const dispatch = useAppDispatch();
const page = useAppSelector((state) => state.cache.instructorPage);
const search = useAppSelector((state) => state.instructors.search);

const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<>
<div className="relative flex border-b border-b-gray-500 text-gray-500 dark:border-b-zinc-400 dark:text-zinc-300">
<span className="absolute inset-y-0 left-0 flex items-center">
<MagnifyingGlassIcon className="h-5 w-5" />
</span>
<input
autoFocus
className="flex-1 py-2 pl-7 text-xl placeholder-gray-300 bg-transparent focus:outline-none dark:placeholder-zinc-500"
type="search"
value={search}
onChange={onChange}
placeholder="Search instructors by name..."
/>
</div>
<div className="flex justify-between">
<div className="text-gray-400 mt-3 text-sm">{numResults} results</div>
</div>
</>
);
};

export default InstructorSearch;
Loading

0 comments on commit 124ac0b

Please sign in to comment.