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 7 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 { getProfessors } from "./controllers/professors.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("/professors").get(getProfessors);

// the next parameter is needed!
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
Expand Down
31 changes: 31 additions & 0 deletions backend/src/controllers/professors.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { RequestHandler } from "express";
import { PrismaReturn } from "../util.mjs";
import prisma from "../models/prisma.mjs";

const getAllProfessorsDbQuery = {
select: {
name: true,
},
};

export interface GetProfessors {
params: unknown;
resBody: PrismaReturn<typeof prisma.professors.findMany<typeof getAllProfessorsDbQuery>>;
reqBody: unknown;
query: unknown;
}

export const getProfessors: RequestHandler<
GetProfessors["params"],
GetProfessors["resBody"],
GetProfessors["reqBody"],
GetProfessors["query"]
> = async (req, res, next) => {
try {
const professors = await prisma.professors.findMany(getAllProfessorsDbQuery);
professors.sort((a, b) => a.name.localeCompare(b.name));
res.json(professors);
} catch (e) {
next(e);
}
};
24 changes: 24 additions & 0 deletions frontend/src/app/api/professors.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 FetchAllProfessorsType = { name: string }[];

export const fetchAllProfessors = createAsyncThunk<
FetchAllProfessorsType,
void,
{ state: RootState }
>("fetchAllProfessors", async (_, thunkAPI) => {
const url = `${process.env.backendUrl}/professors`;
const state = thunkAPI.getState();

if (state.cache.allProfessors.length > 0) return;

return (
await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
})
).json();
});
27 changes: 27 additions & 0 deletions frontend/src/app/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
FetchCourseInfosByPageResult,
} from "./api/course";
import { fetchFCEInfosByCourse, fetchFCEInfosByInstructor } from "./api/fce";
import { fetchAllProfessors } from "./api/professors";

/**
* This cache lasts for the duration of the user session
Expand All @@ -29,6 +30,9 @@ interface CacheState {
coursesLoading: boolean;
exactResultsCourses: string[];
allCourses: { courseID: string; name: string }[];
allProfessors: { name: string }[];
professorsLoading: boolean;
professorPage: number;
}

const initialState: CacheState = {
Expand All @@ -45,6 +49,9 @@ const initialState: CacheState = {
coursesLoading: false,
exactResultsCourses: [],
allCourses: [],
allProfessors: [],
professorsLoading: false,
professorPage: 1,
};

export const selectCourseResults =
Expand Down Expand Up @@ -78,6 +85,11 @@ export const selectFCEResultsForInstructor =
(state: RootState): FCE[] | undefined =>
state.cache.instructorResults[name];

export const selectProfessors = (search: string) => (state: RootState) =>
state.cache.allProfessors.filter((prof) =>
prof.name.toLowerCase().includes(search.toLowerCase())
Lhwhatever marked this conversation as resolved.
Show resolved Hide resolved
);

export const cacheSlice = createSlice({
name: "cache",
initialState,
Expand All @@ -92,6 +104,12 @@ export const cacheSlice = createSlice({
setCoursesLoading: (state, action: PayloadAction<boolean>) => {
state.coursesLoading = action.payload;
},
setProfessorPage: (state, action: PayloadAction<number>) => {
state.professorPage = action.payload;
},
setProfessorsLoading: (state, action: PayloadAction<boolean>) => {
state.professorsLoading = action.payload;
},
},
extraReducers: (builder) => {
builder
Expand Down Expand Up @@ -187,6 +205,15 @@ export const cacheSlice = createSlice({

state.instructorResults[action.meta.arg] = action.payload;
});

builder
.addCase(fetchAllProfessors.pending, (state) => {
state.professorsLoading = true;
})
.addCase(fetchAllProfessors.fulfilled, (state, action) => {
state.professorsLoading = false;
if (action.payload) state.allProfessors = action.payload;
});
},
});

Expand Down
21 changes: 21 additions & 0 deletions frontend/src/app/professors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";

export interface ProfessorsState {
search: string;
}

const initialState: ProfessorsState = {
search: "",
};

export const professorsSlice = createSlice({
name: "professors",
initialState,
reducers: {
updateSearch: (state, action: PayloadAction<string>) => {
state.search = action.payload;
},
},
});

export const reducer = professorsSlice.reducer;
21 changes: 21 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 professorsReducer, ProfessorsState } from "./professors";
import debounce from "lodash/debounce";
import {
FLUSH,
Expand Down Expand Up @@ -62,6 +63,15 @@ const reducers = combineReducers({
},
uiReducer
),
professors: persistReducer<ProfessorsState>(
{
key: "professors",
version: 1,
storage,
stateReconciler: autoMergeLevel2,
},
professorsReducer
),
});

export const store = configureStore({
Expand Down Expand Up @@ -96,6 +106,17 @@ export const throttledFilter = () => {
debouncedFilter();
};

const debouncedProfessorFilter = debounce(() => {
setTimeout(() => {
void store.dispatch(cacheSlice.actions.setProfessorsLoading(false));
}, 0);
}, 300);

export const throttledProfessorFilter = () => {
void store.dispatch(cacheSlice.actions.setProfessorsLoading(true));
debouncedProfessorFilter();
}

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
45 changes: 45 additions & 0 deletions frontend/src/components/ProfessorSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { MagnifyingGlassIcon } from "@heroicons/react/24/solid";
import React from "react";
import { useAppDispatch, useAppSelector } from "../app/hooks";
import { professorsSlice } from "../app/professors";
import { cacheSlice } from "../app/cache";
import { selectProfessors } from "../app/cache";
import { throttledProfessorFilter } from "../app/store";

const ProfessorSearch = () => {
const dispatch = useAppDispatch();
const page = useAppSelector((state) => state.cache.professorPage);
const search = useAppSelector((state) => state.professors.search);

const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch(professorsSlice.actions.updateSearch(e.target.value));
if (page !== 1) dispatch(cacheSlice.actions.setProfessorPage(1));
throttledProfessorFilter();
};

const results = useAppSelector(selectProfessors(search));
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 professors by name..."
/>
</div>
<div className="flex justify-between">
<div className="text-gray-400 mt-3 text-sm">{numResults} results</div>
</div>
</>
);
};

export default ProfessorSearch;
55 changes: 55 additions & 0 deletions frontend/src/components/ProfessorSearchList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useAppDispatch, useAppSelector } from "../app/hooks";
import Loading from "./Loading";
import { Pagination } from "./Pagination";
import React, { useEffect } from "react";
import InstructorDetail from "./InstructorDetail";
import { fetchAllProfessors } from "../app/api/professors";
import { selectProfessors, cacheSlice } from "../app/cache";

const RESULTS_PER_PAGE = 10;

const ProfessorSearchList = () => {
const dispatch = useAppDispatch();

useEffect(() => {
void dispatch(fetchAllProfessors());
}, []);

const search = useAppSelector((state) => state.professors.search);
const results = useAppSelector(selectProfessors(search));
const pages = Math.ceil(results.length / RESULTS_PER_PAGE);
const curPage = useAppSelector((state) => state.cache.professorPage);
const loading = useAppSelector((state) => state.cache.professorsLoading);

const handlePageClick = (page: number) => {
dispatch(cacheSlice.actions.setProfessorPage(page + 1));
};

return (
<div className="p-6">
{loading ? (
<Loading />
) : (
<>
<div className="space-y-4">
{results &&
results
.slice(curPage * RESULTS_PER_PAGE - RESULTS_PER_PAGE, curPage * RESULTS_PER_PAGE)
.map((professor) => (
<InstructorDetail name={professor.name} key={professor.name} showLoading={false}/>
))}
</div>
<div className="mx-auto my-6">
<Pagination
currentPage={curPage - 1}
setCurrentPage={handlePageClick}
totalPages={pages}
/>
</div>
</>
)}
</div>
);
};

export default ProfessorSearchList;
7 changes: 7 additions & 0 deletions frontend/src/components/SideNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ClockIcon,
MagnifyingGlassIcon,
StarIcon,
UserCircleIcon,
} from "@heroicons/react/24/outline";
import React from "react";
import Link from "next/link";
Expand Down Expand Up @@ -80,6 +81,12 @@ export const SideNav = ({ activePage }) => {
newTab
active={false}
/>
<SideNavItem
icon={UserCircleIcon}
text="Professors"
Lhwhatever marked this conversation as resolved.
Show resolved Hide resolved
link="/professors"
active={activePage === "professors"}
/>
</div>
);
};
2 changes: 1 addition & 1 deletion frontend/src/pages/instructor/[name].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const InstructorPage: NextPage = () => {
const name = router.query.name as string;

return (
<Page content={<InstructorDetail name={name} />} sidebar={<Aggregate />} />
<Page content={<InstructorDetail name={name} showLoading={true} />} sidebar={<Aggregate />} />
);
};

Expand Down
Loading
Loading