Skip to content

Commit 5eb1d85

Browse files
authored
Merge pull request #96 from dotkom/feat/search-filters
feat(search): add filters to search
2 parents ac380f7 + 8a4cdb7 commit 5eb1d85

File tree

11 files changed

+350
-33
lines changed

11 files changed

+350
-33
lines changed

common/urls.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,21 @@ interface ListParams {
88
interface CourseListParams extends ListParams {
99
query?: string;
1010
ordering?: string;
11+
facultyId?: number;
12+
departmentId?: number;
1113
}
1214

13-
export const getCourseListApiUrl = ({ limit, offset = 0, query = '', ordering = '-watson_rank' }: CourseListParams) => {
14-
return `${GRADES_API_URL}/api/v2/courses/?limit=${limit}&offset=${offset}&query=${query}&ordering=${ordering}`;
15+
export const getCourseListApiUrl = ({
16+
limit,
17+
offset = 0,
18+
query = '',
19+
ordering = '-watson_rank',
20+
departmentId,
21+
facultyId,
22+
}: CourseListParams) => {
23+
return `${GRADES_API_URL}/api/v2/courses/?limit=${limit}&offset=${offset}&query=${query}&ordering=${ordering}${
24+
departmentId ? `&department=${departmentId}` : ''
25+
}${facultyId ? `&faculty_code=${facultyId}` : ''}`;
1526
};
1627
export const getCourseDetailApiUrl = (courseCode: string) => {
1728
return `${GRADES_API_URL}/api/v2/courses/${courseCode}/`;

components/Graphics/Icons/Filter.tsx

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import React, { FC } from 'react';
2+
3+
interface Props {
4+
className?: string;
5+
}
6+
7+
export const FilterIcon: FC<Props> = ({ className }) => {
8+
return (
9+
<svg
10+
className={className}
11+
aria-hidden="true"
12+
focusable="false"
13+
role="img"
14+
xmlns="http://www.w3.org/2000/svg"
15+
viewBox="0 0 512 512"
16+
>
17+
<path
18+
fill="currentColor"
19+
d="M496 384H160v-16c0-8.8-7.2-16-16-16h-32c-8.8 0-16 7.2-16 16v16H16c-8.8 0-16 7.2-16 16v32c0 8.8 7.2 16 16 16h80v16c0 8.8 7.2 16 16 16h32c8.8 0 16-7.2 16-16v-16h336c8.8 0 16-7.2 16-16v-32c0-8.8-7.2-16-16-16zm0-160h-80v-16c0-8.8-7.2-16-16-16h-32c-8.8 0-16 7.2-16 16v16H16c-8.8 0-16 7.2-16 16v32c0 8.8 7.2 16 16 16h336v16c0 8.8 7.2 16 16 16h32c8.8 0 16-7.2 16-16v-16h80c8.8 0 16-7.2 16-16v-32c0-8.8-7.2-16-16-16zm0-160H288V48c0-8.8-7.2-16-16-16h-32c-8.8 0-16 7.2-16 16v16H16C7.2 64 0 71.2 0 80v32c0 8.8 7.2 16 16 16h208v16c0 8.8 7.2 16 16 16h32c8.8 0 16-7.2 16-16v-16h208c8.8 0 16-7.2 16-16V80c0-8.8-7.2-16-16-16z"
20+
></path>
21+
</svg>
22+
);
23+
};

components/common/button.module.scss

+3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
.base {
22
background-color: unset;
3+
color: var(--text-color);
4+
fill: var(--text-color);
35

46
&:hover {
57
cursor: pointer;
@@ -32,6 +34,7 @@
3234
&.active,
3335
&:active {
3436
&.inverted {
37+
fill: var(--inverted-text-color);
3538
color: var(--inverted-text-color);
3639
background-color: var(--inverted-background-color-offset);
3740
outline: 0;

models/Course.ts

+48
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Grade } from 'models/Grade';
2+
23
export interface Course {
34
id: number;
45
norwegian_name: string;
@@ -30,3 +31,50 @@ export interface Course {
3031
export interface CourseWithGrades extends Course {
3132
grades: Grade[];
3233
}
34+
35+
export type CourseSort =
36+
| 'ranking'
37+
| 'nameDesc'
38+
| 'nameAsc'
39+
| 'courseCodeDesc'
40+
| 'courseCodeAsc'
41+
| 'averageDesc'
42+
| 'averageAsc'
43+
| 'attendeeCountDesc'
44+
| 'attendeeCountAsc';
45+
46+
export const COURSE_SORT_VALUES: CourseSort[] = [
47+
'ranking',
48+
'nameDesc',
49+
'nameAsc',
50+
'courseCodeDesc',
51+
'courseCodeAsc',
52+
'averageDesc',
53+
'averageAsc',
54+
'attendeeCountDesc',
55+
'attendeeCountAsc',
56+
];
57+
58+
export const COURSE_ORDERING: { [Order in CourseSort]: string } = {
59+
ranking: '-watson_rank,-attendee_count',
60+
nameDesc: '-norwegian_name,-watson_rank,-attendee_count',
61+
nameAsc: 'norwegian_name,-watson_rank,-attendee_count',
62+
courseCodeDesc: '-code,-watson_rank,-attendee_count',
63+
courseCodeAsc: 'code,-watson_rank,-attendee_count',
64+
averageDesc: '-average,-watson_rank,-attendee_count',
65+
averageAsc: 'average,-watson_rank,-attendee_count',
66+
attendeeCountDesc: '-attendee_count,-watson_rank,-attendee_count',
67+
attendeeCountAsc: 'attendee_count,-watson_rank,-attendee_count',
68+
};
69+
70+
export const COURSE_SORT_NAMES: { [Order in CourseSort]: string } = {
71+
ranking: 'Relevans',
72+
nameDesc: 'Emnenavn (synkende)',
73+
nameAsc: 'Emnenavn (stigende)',
74+
courseCodeDesc: 'Emnekode (synkende)',
75+
courseCodeAsc: 'Emnekode (stigende)',
76+
averageDesc: 'Snitt (synkende)',
77+
averageAsc: 'Snitt (stigende)',
78+
attendeeCountDesc: 'Antall studenter (synkende)',
79+
attendeeCountAsc: 'Antall studenter (stigende)',
80+
};

models/Department.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export interface Department {
2+
id: number;
3+
acronym: string;
4+
norwegian_name: string;
5+
english_name: string;
6+
organization_unit_id: number;
7+
nsd_code: string;
8+
department_id: number;
9+
faculty: number;
10+
}

models/Faculty.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export interface Faculty {
2+
id: number;
3+
acronym: string;
4+
norwegian_name: string;
5+
english_name: string;
6+
organization_unit_id: number;
7+
nsd_code: string;
8+
faculty_id: number;
9+
}

pages/course/index.tsx

+54-10
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,55 @@
1-
import React, { FC, useCallback, useMemo, useRef } from 'react';
1+
import React, { FC, useCallback, useMemo, useRef, useState } from 'react';
22
import { useSWRInfinite } from 'swr';
33

44
import { fetcher, ListResponse } from 'common/fetcher';
5-
import { getCourseListApiUrl } from 'common/urls';
5+
import { getCourseListApiUrl, getDepartmentListApiUrl, getFacultyListApiUrl } from 'common/urls';
66
import { useQueryParam } from 'common/hooks/useQueryParam';
77
import { useIsomorphicLayoutEffect } from 'common/hooks/useIsomorphicLayoutEffect';
88
import { CourseListView } from 'views/CourseListView';
9-
import { Course } from 'models/Course';
9+
import { Course, CourseSort, COURSE_ORDERING } from 'models/Course';
10+
import { GetStaticProps } from 'next';
11+
import { Department } from 'models/Department';
12+
import { Faculty } from 'models/Faculty';
13+
14+
interface StaticProps {
15+
departments: Department[];
16+
faculties: Faculty[];
17+
}
1018

1119
const PAGE_SIZE = 20;
1220

13-
const getSearchUrlPaginatedGetter = (query: string) => (
14-
pageNumber: number,
15-
previousPageData: ListResponse<Course> | null
16-
) => {
21+
const getSearchUrlPaginatedGetter = (
22+
query: string,
23+
sortOrder: CourseSort,
24+
departmentFilter: number | null,
25+
facultyFilter: number | null
26+
) => (pageNumber: number, previousPageData: ListResponse<Course> | null) => {
1727
if (previousPageData && !previousPageData?.results.length) return null;
1828
const offset = pageNumber * PAGE_SIZE;
19-
const ordering = '-watson_rank,-attendee_count';
29+
const ordering = COURSE_ORDERING[sortOrder] ?? COURSE_ORDERING['ranking'];
2030
return getCourseListApiUrl({
2131
limit: PAGE_SIZE,
2232
offset,
2333
query,
2434
ordering,
35+
facultyId: facultyFilter ?? undefined,
36+
departmentId: departmentFilter ?? undefined,
2537
});
2638
};
2739

28-
const CourseListPage: FC = () => {
40+
const CourseListPage: FC<StaticProps> = ({ departments, faculties }) => {
2941
const searchBarRef = useRef<HTMLInputElement | null>(null);
3042
const [queryParam, setQuery] = useQueryParam('query', '');
43+
const [sortOrder, setSortOrder] = useState<CourseSort>('ranking');
44+
const [departmentId, setDepartmentId] = useState<number | null>(null);
45+
const [facultyId, setFacultyId] = useState<number | null>(null);
3146
const query = Array.isArray(queryParam) ? queryParam.join(',') : queryParam;
32-
const getSearchUrl = useMemo(() => getSearchUrlPaginatedGetter(query), [query]);
47+
const getSearchUrl = useMemo(() => getSearchUrlPaginatedGetter(query, sortOrder, departmentId, facultyId), [
48+
query,
49+
sortOrder,
50+
departmentId,
51+
facultyId,
52+
]);
3353
const { data, isValidating, setSize } = useSWRInfinite<ListResponse<Course>>(getSearchUrl, fetcher);
3454

3555
const nextPage = useCallback(() => setSize((currentSize) => currentSize + 1), []);
@@ -46,6 +66,14 @@ const CourseListPage: FC = () => {
4666
return (
4767
<CourseListView
4868
searchBarRef={searchBarRef}
69+
onOrderingChange={setSortOrder}
70+
currentOrdering={sortOrder}
71+
onFacultyFilterChange={setFacultyId}
72+
onDepartmentFilterChange={setDepartmentId}
73+
departments={departments}
74+
currentDepartmentId={departmentId}
75+
faculties={faculties}
76+
currentFacultyId={facultyId}
4977
query={query}
5078
onSearchChange={setQuery}
5179
courses={courses}
@@ -56,4 +84,20 @@ const CourseListPage: FC = () => {
5684
);
5785
};
5886

87+
export const getStaticProps: GetStaticProps<StaticProps> = async () => {
88+
const [departmentsResponse, facultiesResponse] = await Promise.all([
89+
fetcher<ListResponse<Department>>(getDepartmentListApiUrl()),
90+
fetcher<ListResponse<Faculty>>(getFacultyListApiUrl()),
91+
]);
92+
const departments = departmentsResponse.results;
93+
const faculties = facultiesResponse.results;
94+
return {
95+
revalidate: 60 * 60, // Revalidate once each hour.
96+
props: {
97+
departments,
98+
faculties,
99+
},
100+
};
101+
};
102+
59103
export default CourseListPage;
+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import React, { ChangeEvent, FC, useEffect, useMemo } from 'react';
2+
3+
import { Label } from 'components/forms/Label';
4+
import { Select } from 'components/forms/Select';
5+
import { CourseSort, COURSE_SORT_NAMES, COURSE_SORT_VALUES } from 'models/Course';
6+
import { Department } from 'models/Department';
7+
import { Faculty } from 'models/Faculty';
8+
9+
import styles from './course-filters.module.scss';
10+
11+
interface Props {
12+
onOrderingChange: (sortOrder: CourseSort) => void;
13+
currentOrdering: CourseSort;
14+
onFacultyFilterChange: (facultyId: number | null) => void;
15+
onDepartmentFilterChange: (departmentId: number | null) => void;
16+
currentDepartmentId: number | null;
17+
departments: Department[];
18+
currentFacultyId: number | null;
19+
faculties: Faculty[];
20+
}
21+
22+
export const CourseFilters: FC<Props> = ({
23+
onOrderingChange,
24+
currentOrdering,
25+
onFacultyFilterChange,
26+
onDepartmentFilterChange,
27+
currentDepartmentId,
28+
departments,
29+
currentFacultyId,
30+
faculties,
31+
}) => {
32+
const handleOrderingChange = (event: ChangeEvent<HTMLSelectElement>) => {
33+
onOrderingChange(event.target.value as CourseSort);
34+
};
35+
36+
const handleFacultyChange = (event: ChangeEvent<HTMLSelectElement>) => {
37+
onFacultyFilterChange(Number(event.target.value) || null);
38+
};
39+
40+
const handleDepartmentChange = (event: ChangeEvent<HTMLSelectElement>) => {
41+
onDepartmentFilterChange(Number(event.target.value) || null);
42+
};
43+
44+
const departmentsFilteredByFaculty = useMemo(() => {
45+
const faculty = faculties.find((f) => f.faculty_id === currentFacultyId);
46+
if (faculty) {
47+
return departments.filter((department) => department.faculty === faculty.id);
48+
}
49+
return departments;
50+
}, [currentFacultyId, String(faculties.map((f) => f.id))]);
51+
52+
useEffect(() => {
53+
if (currentFacultyId) {
54+
onDepartmentFilterChange(null);
55+
}
56+
}, [currentFacultyId]);
57+
58+
return (
59+
<div className={styles.menu}>
60+
<Label label="Sortering">
61+
<Select onChange={handleOrderingChange} value={currentOrdering}>
62+
{COURSE_SORT_VALUES.map((value) => (
63+
<option key={value} value={value}>
64+
{COURSE_SORT_NAMES[value]}
65+
</option>
66+
))}
67+
</Select>
68+
</Label>
69+
<Label label="Fakultet">
70+
<Select onChange={handleFacultyChange} value={currentFacultyId ?? 0}>
71+
<option value={0}>Ikke valgt</option>
72+
{faculties.map((faculty) => (
73+
<option key={faculty.id} value={faculty.faculty_id}>
74+
{`(${faculty.acronym}) ${faculty.norwegian_name}`}
75+
</option>
76+
))}
77+
</Select>
78+
</Label>
79+
<Label label="Institutt">
80+
<Select onChange={handleDepartmentChange} value={currentDepartmentId ?? 0}>
81+
<option value={0}>Ikke valgt</option>
82+
{departmentsFilteredByFaculty.map((department) => (
83+
<option key={department.id} value={department.id}>
84+
{`(${department.acronym}) ${department.norwegian_name}`}
85+
</option>
86+
))}
87+
</Select>
88+
</Label>
89+
</div>
90+
);
91+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
.menu {
2+
display: grid;
3+
grid-template-columns: 1fr;
4+
column-gap: var(--spacing-4);
5+
row-gap: var(--spacing-1);
6+
margin-bottom: var(--spacing-4);
7+
8+
@media screen and (min-width: 480px) {
9+
grid-template-columns: 1fr 1fr 1fr;
10+
}
11+
}

views/CourseListView/course-list-view.module.scss

+16
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
grid-template-columns: 1fr;
55
}
66

7+
.searchContainer {
8+
display: flex;
9+
gap: var(--spacing-2);
10+
}
11+
712
.searchBar {
813
margin: var(--spacing-2) 0;
914
}
@@ -26,3 +31,14 @@
2631
margin: var(--spacing-4) auto;
2732
text-align: center;
2833
}
34+
35+
.toggleFiltersButton {
36+
height: fit-content;
37+
margin: auto 0;
38+
padding: var(--spacing-2);
39+
}
40+
41+
.filtersIcon {
42+
height: 20px;
43+
width: 20px;
44+
}

0 commit comments

Comments
 (0)