Skip to content

Commit

Permalink
Advanced Filters for Course and Instructor Pages (#138)
Browse files Browse the repository at this point in the history
* First MVP for advanced filters for course and instructor pages

* Moved instructors above feedback cos it makes more sense

---------

Co-authored-by: “xavilien” <“[email protected]”>
  • Loading branch information
Xavilien and “xavilien” authored Apr 1, 2024
1 parent 2cfb585 commit 77e0638
Show file tree
Hide file tree
Showing 11 changed files with 363 additions and 15 deletions.
18 changes: 14 additions & 4 deletions frontend/src/app/fce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ export interface AggregateFCEsOptions {
summer: boolean;
fall: boolean;
};
courses?: string[];
filters: {
type: string;
courses: string[];
instructors: string[];
}
numSemesters: number;
}

Expand All @@ -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)
);
}

Expand Down Expand Up @@ -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) {
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/app/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export interface UserState {
summer: boolean;
fall: boolean;
};
filters: {
type: string;
courses: string[];
instructors: string[];
}
};
token: string;
}
Expand All @@ -31,6 +36,11 @@ const initialState: UserState = {
summer: false,
fall: true,
},
filters: {
type: "",
courses: [],
instructors: [],
},
},
token: null,
};
Expand Down Expand Up @@ -77,6 +87,16 @@ export const userSlice = createSlice({
setToken: (state, action: PayloadAction<string>) => {
state.token = action.payload;
},
setFilters: (state, action: PayloadAction<UserState["fceAggregation"]["filters"]>) => {
state.fceAggregation.filters = action.payload;
},
resetFilters: (state) => {
state.fceAggregation.filters = {
type: "",
courses: [],
instructors: [],
};
}
},
});

Expand Down
4 changes: 4 additions & 0 deletions frontend/src/app/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(arr: T[]): T[] {
return Array.from(new Set(arr));
}
3 changes: 3 additions & 0 deletions frontend/src/components/CourseSearchList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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));
};
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/components/InstructorDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -38,9 +39,11 @@ const InstructorDetail = ({ name, showLoading }: Props) => {
<div className="m-auto space-y-4 p-6">
<Card>
<div>
<div className="text-md text-gray-800 font-semibold">
{toNameCase(name)}
</div>
<Link href={`/instructor/${name}`}>
<div className="text-md text-gray-800 font-semibold">
{toNameCase(name)}
</div>
</Link>
{/* TODO: Add more information about instructor using Directory API */}
</div>
<div>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/InstructorSearchList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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));
};
Expand Down
12 changes: 6 additions & 6 deletions frontend/src/components/SideNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,19 +74,19 @@ export const SideNav = ({ activePage }) => {
link="/schedules"
active={activePage === "schedules"}
/>
<SideNavItem
icon={UserCircleIcon}
text="Instructors"
link="/instructors"
active={activePage === "instructors"}
/>
<SideNavItem
icon={ChatBubbleBottomCenterTextIcon}
text="Feedback"
link="https://forms.gle/6vPTN6Eyqd1w7pqJA"
newTab
active={false}
/>
<SideNavItem
icon={UserCircleIcon}
text="Instructors"
link="/instructors"
active={activePage === "instructors"}
/>
</div>
);
};
144 changes: 144 additions & 0 deletions frontend/src/components/filters/CourseFilter.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative mt-1">
<Combobox value={filteredCourses} onChange={setCourses} multiple>
<div className="text-lg">Courses</div>
<Combobox.Button
className="border-gray-200 relative mt-2 w-full cursor-default rounded border py-1 pl-1 pr-10 text-left transition duration-150 ease-in-out sm:text-sm sm:leading-5">
<span className="flex flex-wrap gap-1">
<span className="text-blue-800 bg-blue-50 flex items-center gap-1 rounded px-2 py-0.5">
{noSelectAll().length} selected
</span>
<Combobox.Input
className="shadow-xs bg-white rounded py-0.5 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5 flex"
onChange={(e) => 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();
}
}}
/>
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 stroke-gray-500 dark:stroke-zinc-400"/>
</span>
</Combobox.Button>
<div className="bg-white absolute mt-1 w-full rounded shadow-lg">
<Combobox.Options
className="shadow-xs bg-white relative z-50 max-h-60 overflow-auto rounded py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5"
>
<span onClick={toggleAllCourses}>
<Combobox.Option
value={"SELECT ALL"}
className="relative cursor-pointer select-none py-2 pl-3 pr-9 focus:outline-none"
disabled={true}
>
<span className={"block truncate"}>
<span
className={classNames(
"text-gray-700 ml-1",
noSelectAll().length === courses?.length ? "font-semibold" : "font-normal"
)}
>
SELECT ALL
</span>
</span>
{noSelectAll().length === courses?.length && (
<span className="absolute inset-y-0 right-0 flex items-center pr-4">
<CheckIcon className="h-5 w-5"/>
</span>
)}
</Combobox.Option>
</span>
{courses?.filter(searchCourses).map((name) => (
<Combobox.Option
key={name}
value={name}
className="relative cursor-pointer select-none py-2 pl-3 pr-9 focus:outline-none "
>
{({selected}) => (
<>
<span className={"block truncate"}>
<span
className={classNames(
"text-gray-700 ml-1",
selected ? "font-semibold" : "font-normal"
)}
>
{name}
</span>
</span>
{selected && (
<span className="absolute inset-y-0 right-0 flex items-center pr-4">
<CheckIcon className="h-5 w-5"/>
</span>
)}
</>
)}
</Combobox.Option>
))}
</Combobox.Options>
</div>
</Combobox>
</div>
);
};

export default CourseFilter;
Loading

0 comments on commit 77e0638

Please sign in to comment.