Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Instructor Search #137

Merged
merged 16 commits into from
Mar 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not too sure on the Fuse library API so I will trust this here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Loading