Skip to content

Commit b42a2c3

Browse files
committed
Add pagination, infinite scroll and dept filter
Backend: add department_filter and register it in the CourseSearchFilterBackend; introduce CoursePagination (page_size 100, max 500) and enable pagination on CourseList. CourseList now snaps to the current semester when certain semester-specific filters (days, time, instructor_quality) are present and the requested semester is "all". Frontend: extend apiCourseSearch to accept departments and page parameters. Implement fetchCourses with paginated requests, infinite scroll (IntersectionObserver + sentinel), loading-more spinner, and display of total result count. Show a semester banner when semester-specific filters are active. Update CourseResultsTable to render data from either nested review objects or flat course fields, add a scroll container and sentinel, and include CSS to make the table header sticky. Misc: layout/flex fixes in BrowsePage and container sizing adjustments to ensure proper scrolling behavior.
1 parent fcf5ead commit b42a2c3

7 files changed

Lines changed: 248 additions & 85 deletions

File tree

backend/courses/filters.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,20 @@ def filter_choices(queryset, choices):
314314
return filter_choices
315315

316316

317+
def department_filter(queryset, departments):
318+
"""
319+
Filters courses by department code(s) with OR logic.
320+
:param queryset: Course queryset
321+
:param departments: Pipe-separated department codes (e.g. "CIS|NETS|ESE")
322+
"""
323+
if not departments:
324+
return queryset
325+
codes = [code.strip().upper() for code in departments.split("|") if code.strip()]
326+
if not codes:
327+
return queryset
328+
return queryset.filter(department__code__in=codes)
329+
330+
317331
def degree_rules_filter(queryset, rule_ids):
318332
"""
319333
:param queryset: initial Course object queryset
@@ -347,6 +361,7 @@ def filter_queryset(self, request, queryset, view):
347361
"difficulty": bound_filter("difficulty"),
348362
"is_open": is_open_filter,
349363
"rule_ids": degree_rules_filter,
364+
"departments": department_filter,
350365
}
351366
for field, filter_func in filters.items():
352367
param = request.query_params.get(field)

backend/courses/views.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from django_auto_prefetching import AutoPrefetchViewSetMixin
88
from rest_framework import generics, status
99
from rest_framework.exceptions import ValidationError
10+
from rest_framework.pagination import PageNumberPagination
1011
from rest_framework.permissions import IsAuthenticated
1112
from rest_framework.response import Response
1213
from rest_framework.views import APIView
@@ -139,6 +140,12 @@ def get_semester_field(self):
139140
return "course__semester"
140141

141142

143+
class CoursePagination(PageNumberPagination):
144+
page_size = 100
145+
page_size_query_param = "page_size"
146+
max_page_size = 500
147+
148+
142149
class CourseList(generics.ListAPIView, BaseCourseMixin):
143150
"""
144151
Retrieve a list of (all) courses for the provided semester.
@@ -154,8 +161,24 @@ class CourseList(generics.ListAPIView, BaseCourseMixin):
154161
)
155162

156163
serializer_class = CourseListSerializer
164+
pagination_class = CoursePagination
165+
filter_backends = [CourseSearchFilterBackend]
157166
queryset = Course.with_reviews.filter(sections__isnull=False) # included redundantly for docs
158167

168+
# These params filter on schedule/section data that only exists in a specific semester,
169+
# so requesting them on the "all" semester path automatically snaps to current semester.
170+
_SEMESTER_SPECIFIC_PARAMS = frozenset({"days", "time", "instructor_quality"})
171+
172+
def get_semester(self):
173+
semester = super().get_semester()
174+
if semester == "all" and self._SEMESTER_SPECIFIC_PARAMS.intersection(
175+
self.request.query_params
176+
):
177+
current = get_current_semester(allow_not_found=True)
178+
if current:
179+
return current
180+
return semester
181+
159182
def get_queryset(self):
160183
queryset = Course.with_reviews.filter(sections__isnull=False)
161184
queryset = queryset.prefetch_related(

frontend/review/public/static/css/base.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,6 +1013,25 @@ hr {
10131013
border-bottom: none !important;
10141014
}
10151015

1016+
/* Sticky header: kill overflow on all ancestors so sticky works within the outer scroll container */
1017+
.course-results-table.ReactTable {
1018+
overflow: visible !important;
1019+
}
1020+
1021+
.course-results-table .rt-table {
1022+
overflow: visible !important;
1023+
}
1024+
1025+
.course-results-table .rt-tbody {
1026+
overflow: visible !important;
1027+
}
1028+
1029+
.course-results-table .rt-thead {
1030+
position: sticky;
1031+
top: 0;
1032+
z-index: 1;
1033+
}
1034+
10161035
/* Target the header cells */
10171036
.course-results-table .rt-thead .rt-th {
10181037
display: flex;

frontend/review/src/components/CourseResults.js

Lines changed: 127 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useEffect, useContext } from 'react';
1+
import React, { useState, useEffect, useContext, useRef, useCallback } from 'react';
22
import styled from 'styled-components';
33
import { apiAutocomplete, apiReviewData, apiCourseSearch } from '../utils/api';
44
import { useHistory } from 'react-router-dom';
@@ -20,7 +20,9 @@ const Container = styled.div`
2020
gap: 12px;
2121
border-radius: 12px;
2222
background: #FFF;
23-
height: fit-content;
23+
flex: 1;
24+
min-height: 0;
25+
overflow: hidden;
2426
`;
2527

2628
const BrowsingTitle = styled.span`
@@ -127,6 +129,20 @@ const SpecialPromptContainer = styled.div`
127129
padding: 40px 0;
128130
`;
129131

132+
const SemesterBanner = styled.div`
133+
display: flex;
134+
align-items: center;
135+
gap: 8px;
136+
width: 100%;
137+
padding: 10px 14px;
138+
border-radius: 8px;
139+
background: #EBF5FF;
140+
border: 1px solid #B3D7F5;
141+
color: #1A6FAF;
142+
font-size: 14px;
143+
font-weight: 400;
144+
`;
145+
130146
const numFiltersChanged = (filters) => {
131147
let count = 0;
132148
for(const [key, value] of Object.entries(filters)) {
@@ -144,20 +160,44 @@ const numFiltersChanged = (filters) => {
144160
return count;
145161
}
146162

163+
const SEMESTER_SPECIFIC_FILTERS = ['instructor_quality', 'days', 'time'];
164+
165+
const getActiveSemesterFilters = (filters) => {
166+
return SEMESTER_SPECIFIC_FILTERS.filter(key => !isDefault(key, filters[key]));
167+
};
168+
169+
const SEMESTER_FILTER_LABELS = {
170+
instructor_quality: 'Instructor Quality',
171+
days: 'Days Offered',
172+
time: 'Time Offered',
173+
};
174+
175+
const isDefault = (key, value) => {
176+
const def = DEFAULT_FILTERS[key];
177+
if (Array.isArray(value)) {
178+
return value.length === def.length && value.every(v => def.includes(v));
179+
}
180+
return value === def;
181+
};
182+
147183
const formatFiltersForAPI = (filters) => {
148184
const formatted = {};
149185
for(const [key, value] of Object.entries(filters)) {
150-
if (key === 'difficulty' || key === 'course_quality' || key === 'instructor_quality') {
186+
if (key === 'semester') {
187+
formatted[key] = value === 'Any' ? "all" : "current";
188+
} else if (isDefault(key, value)) {
189+
continue;
190+
} else if (key === 'difficulty' || key === 'course_quality' || key === 'instructor_quality') {
151191
formatted[key] = `${value[0]}-${value[1]}`;
152192
} else if (key === 'days') {
153193
formatted[key] = value.join('');
154194
} else if (key === 'time') {
155195
formatted[key] = value;
156-
} else if (key === 'semester') {
157-
formatted[key] = value === 'Any' ? "all" : "current";
158196
} else if (key === 'attributes') {
159197
formatted[key] = value.join('|');
160-
} //ALSO ADD DEPARTMENT WHEN IT BECOMES SUPPORTED
198+
} else if (key === 'departments') {
199+
formatted[key] = value.join('|');
200+
}
161201
}
162202
return formatted;
163203
}
@@ -168,7 +208,13 @@ const CourseResults = ({ filters, setFilters }) => {
168208
const [departments, setDepartments] = useState([]);
169209
const [filteredResults, setFilteredResults] = useState(numFiltersChanged(filters) > 0 ? {} : null);
170210
const [isLoading, setIsLoading] = useState(false);
211+
const [isLoadingMore, setIsLoadingMore] = useState(false);
171212
const [isAverage, setIsAverage] = useState(true);
213+
const [totalCount, setTotalCount] = useState(0);
214+
const [hasMore, setHasMore] = useState(false);
215+
const nextPageRef = useRef(2);
216+
const sentinelRef = useRef(null);
217+
const isLoadingRef = useRef(false);
172218

173219
const [recencyOption, setRecencyOption] = useState('Average Rating');
174220

@@ -184,48 +230,75 @@ const CourseResults = ({ filters, setFilters }) => {
184230
});
185231
}, []);
186232

187-
useEffect(() => {
188-
let isActivelyFiltering = false;
189-
isActivelyFiltering = numFiltersChanged(filters) > 0;
190-
console.log('Filters changed:', filters, 'Actively filtering:', isActivelyFiltering);
191-
192-
if (isActivelyFiltering) {
193-
//TEMPORARY: replace with actual backend route for filtering based on all filters, not just department
233+
const fetchCourses = useCallback((filters, page, append = false) => {
234+
if (isLoadingRef.current) return;
235+
isLoadingRef.current = true;
236+
if (append) {
237+
setIsLoadingMore(true);
238+
} else {
194239
setIsLoading(true);
195-
console.log('loading true')
196-
// apiReviewData('department', filters.departments[0], '')
197-
// .then(data => {
198-
// console.log("Fetched review data for filtering:", data);
199-
// setFilteredResults(data);
200-
// })
201-
// .catch(error => {
202-
// console.error("Error fetching review data:", error);
203-
// setFilteredResults({});
204-
// })
205-
// .finally(() => {
206-
// setIsLoading(false);
207-
// console.log('loading false')
208-
// });
209-
const ff = formatFiltersForAPI(filters);
210-
console.log('Formatted filters for API:', ff);
211-
apiCourseSearch(ff.semester, ff.attributes, ff.difficulty, ff.course_quality, ff.days, ff.time)
212-
.then(data => {
213-
console.log("Fetched course search data:", data);
214-
setFilteredResults(data);
215-
})
216-
.catch(error => {
217-
console.error("Error fetching course search data:", error);
240+
}
241+
const ff = formatFiltersForAPI(filters);
242+
apiCourseSearch(ff.semester, ff.attributes, ff.difficulty, ff.course_quality, ff.instructor_quality, ff.days, ff.time, ff.departments, page)
243+
.then(data => {
244+
const newResults = (data.results || []).reduce((acc, course) => {
245+
acc[course.id] = course;
246+
return acc;
247+
}, {});
248+
if (append) {
249+
setFilteredResults(prev => ({ ...prev, ...newResults }));
250+
} else {
251+
setFilteredResults(newResults);
252+
}
253+
setTotalCount(data.count || 0);
254+
setHasMore(data.next !== null);
255+
nextPageRef.current = page + 1;
256+
})
257+
.catch(error => {
258+
console.error("Error fetching course search data:", error);
259+
if (!append) {
218260
setFilteredResults({});
219-
})
220-
.finally(() => {
221-
setIsLoading(false);
222-
console.log('loading false')
223-
});
261+
setTotalCount(0);
262+
}
263+
setHasMore(false);
264+
})
265+
.finally(() => {
266+
isLoadingRef.current = false;
267+
setIsLoading(false);
268+
setIsLoadingMore(false);
269+
});
270+
}, []);
271+
272+
useEffect(() => {
273+
const isActivelyFiltering = numFiltersChanged(filters) > 0;
274+
275+
if (isActivelyFiltering) {
276+
nextPageRef.current = 2;
277+
fetchCourses(filters, 1);
224278
} else {
225279
setFilteredResults(null);
280+
setTotalCount(0);
281+
setHasMore(false);
226282
}
283+
}, [filters, fetchCourses]);
284+
285+
// Infinite scroll — sentinel is inside the table's scroll area
286+
useEffect(() => {
287+
const sentinel = sentinelRef.current;
288+
if (!sentinel || !hasMore) return;
289+
290+
const observer = new IntersectionObserver(
291+
(entries) => {
292+
if (entries[0].isIntersecting && !isLoadingRef.current) {
293+
fetchCourses(filters, nextPageRef.current, true);
294+
}
295+
},
296+
{ threshold: 0.1 }
297+
);
227298

228-
}, [filters]);
299+
observer.observe(sentinel);
300+
return () => observer.disconnect();
301+
}, [hasMore, filters, fetchCourses]);
229302

230303
const history = useHistory();
231304

@@ -247,7 +320,7 @@ const CourseResults = ({ filters, setFilters }) => {
247320
Object.entries(filteredResults).length > 0 ? (
248321
<>
249322
<SearchResultsHeader>
250-
<span>Showing <b>{Object.entries(filteredResults).length}</b> Search Results ({numFiltersChanged(filters)} filter{numFiltersChanged(filters) !== 1 ? "s" : ""})</span>
323+
<span>Showing <b>{Object.keys(filteredResults).length}</b> of <b>{totalCount}</b> Search Results ({numFiltersChanged(filters)} filter{numFiltersChanged(filters) !== 1 ? "s" : ""})</span>
251324
<CustomDropdown
252325
style={{width: '180px', selfAlign: 'center'}}
253326
options={['Average Rating', 'Most Recent Rating']}
@@ -258,10 +331,20 @@ const CourseResults = ({ filters, setFilters }) => {
258331
}}
259332
/>
260333
</SearchResultsHeader>
261-
<div style={{width: '100%', marginBottom: '16px', height: 'fit-content'}}>
334+
{getActiveSemesterFilters(filters).length > 0 && (
335+
<SemesterBanner>
336+
<i className="fa fa-info-circle" />
337+
<span>
338+
Filtering by {getActiveSemesterFilters(filters).map(k => SEMESTER_FILTER_LABELS[k]).join(', ')} — results limited to current semester offerings.
339+
</span>
340+
</SemesterBanner>
341+
)}
342+
<div style={{width: '100%'}}>
262343
<CourseResultsTable
263344
filteredResults={filteredResults}
264345
isAverage={isAverage}
346+
sentinelRef={sentinelRef}
347+
isLoadingMore={isLoadingMore}
265348
/>
266349
</div>
267350
</>

0 commit comments

Comments
 (0)