Skip to content

Commit 207ff05

Browse files
committed
Added pagination
1 parent 0bbdbca commit 207ff05

5 files changed

Lines changed: 100 additions & 29 deletions

File tree

backend/courses/models.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,8 +131,8 @@ def course_reviews(queryset, prefix=""):
131131
def cache_or_compute_course_reviews(queryset):
132132
"""
133133
A helper function which takes a Course queryset and annotates it with review averages, either by
134-
using cached precompute fields if the annotations have expired or by computing the averages
135-
from the reviews if they haven't.
134+
using cached precompute fields if the annotations have not expired or by computing the averages
135+
from the reviews if they have expired or are missing.
136136
"""
137137
if queryset.filter(annotation_expiration__lt=timezone.now()).exists():
138138
return course_reviews(queryset).order_by("full_code", "semester")
@@ -602,7 +602,8 @@ def __str__(self):
602602
def cache_or_compute_section_reviews(queryset):
603603
"""
604604
A helper function which takes a Section queryset and annotates it with review averages, either
605-
by using cached precompute fields if the annotations have expired or by computing the averages
605+
by using cached precompute fields if the annotations have not expired or by computing the
606+
averages if they have expired or are missing.
606607
"""
607608
if queryset.filter(annotation_expiration__lt=timezone.now()).exists():
608609
return sections_with_reviews(queryset).order_by("full_code")

backend/courses/views.py

Lines changed: 17 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
@@ -147,6 +148,21 @@ def get_semester_field(self):
147148
return "course__semester"
148149

149150

151+
class OptionalPageNumberPagination(PageNumberPagination):
152+
"""
153+
Pagination that only activates when `page` or `page_size` is present in the request.
154+
When neither is provided, the full result set is returned unpaginated.
155+
"""
156+
157+
page_size_query_param = "page_size"
158+
page_size = 100
159+
160+
def paginate_queryset(self, queryset, request, view=None):
161+
if "page" not in request.query_params and "page_size" not in request.query_params:
162+
return None
163+
return super().paginate_queryset(queryset, request, view)
164+
165+
150166
class CourseList(generics.ListAPIView, BaseCourseMixin):
151167
"""
152168
Retrieve a list of (all) courses for the provided semester.
@@ -244,6 +260,7 @@ def get_serializer_context(self):
244260

245261
filter_backends = [TypedCourseSearchBackend, CourseSearchFilterBackend]
246262
search_fields = ("full_code", "title", "sections__instructors__name")
263+
pagination_class = OptionalPageNumberPagination
247264

248265

249266
class CourseDetail(generics.RetrieveAPIView, BaseCourseMixin):

frontend/degree-plan/components/Search/ResultsList.tsx

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useRef } from "react";
1+
import React, { useCallback, useEffect, useRef } from "react";
22
import styled from '@emotion/styled';
33
import Course, { SkeletonCourse } from "./CourseInSearch";
44
import { Course as CourseType, DegreePlan, DockedCourse, Fulfillment, Rule } from "../../types";
@@ -53,16 +53,41 @@ export interface ResultListProps {
5353
fulfillments: Fulfillment[],
5454
ruleId: Rule["id"] | null;
5555
isLoading: boolean;
56+
isLoadingMore: boolean;
57+
hasMore: boolean;
58+
loadMore: () => void;
5659
}
5760
const ResultsList = ({
5861
ruleId,
5962
activeDegreeplanId,
6063
fulfillments,
6164
courses,
62-
isLoading
65+
isLoading,
66+
isLoadingMore,
67+
hasMore,
68+
loadMore,
6369
}: ResultListProps) => {
6470
// TODO: what if activeDegreeplan is not defined
6571

72+
const observerRef = useRef<IntersectionObserver | null>(null);
73+
const sentinelRef = useCallback(
74+
(node: HTMLLIElement | null) => {
75+
if (observerRef.current) {
76+
observerRef.current.disconnect();
77+
observerRef.current = null;
78+
}
79+
if (!node) return;
80+
observerRef.current = new IntersectionObserver(
81+
(entries) => {
82+
if (entries[0].isIntersecting) loadMore();
83+
},
84+
{ threshold: 0.1 }
85+
);
86+
observerRef.current.observe(node);
87+
},
88+
[loadMore]
89+
);
90+
6691
const { createOrUpdate: createOrUpdateFulfillment } = useSWRCrud<Fulfillment>(
6792
`/api/degree/degreeplans/${activeDegreeplanId}/fulfillments`,
6893
{
@@ -80,21 +105,25 @@ const ResultsList = ({
80105
<Header width="20%">DIFF</Header>
81106
</HeaderContainer>
82107
<CoursesContainer>
83-
{!isLoading ? courses.map((course) =>
84-
<Course
85-
ruleId={ruleId}
86-
key={course.id + course.semester}
87-
course={course}
88-
onClick={() => {
89-
if (ruleId) {
90-
const rules = fulfillments.find(fulfillment => fulfillment.full_code == course.id)?.rules || [];
91-
createOrUpdateFulfillment({ rules: [...rules, ruleId] }, course.id);
92-
} else createOrUpdateDockedCourse({}, course.id);
93-
}}
94-
// star means the course is a fulfillment
95-
isStar={!!fulfillments.find((fulfillment) => fulfillment.full_code == course.id)}
96-
/>) :
97-
Array.from(Array(6).keys()).map(() => <SkeletonCourse />)
108+
{!isLoading ? <>
109+
{courses.map((course) =>
110+
<Course
111+
ruleId={ruleId}
112+
key={course.id + course.semester}
113+
course={course}
114+
onClick={() => {
115+
if (ruleId) {
116+
const rules = fulfillments.find(fulfillment => fulfillment.full_code == course.id)?.rules || [];
117+
createOrUpdateFulfillment({ rules: [...rules, ruleId] }, course.id);
118+
} else createOrUpdateDockedCourse({}, course.id);
119+
}}
120+
// star means the course is a fulfillment
121+
isStar={!!fulfillments.find((fulfillment) => fulfillment.full_code == course.id)}
122+
/>)}
123+
{isLoadingMore && Array.from(Array(3).keys()).map((i) => <SkeletonCourse key={`skeleton-${i}`} />)}
124+
{hasMore && <li ref={sentinelRef} style={{ height: 1 }} />}
125+
</> :
126+
Array.from(Array(6).keys()).map((i) => <SkeletonCourse key={`skeleton-${i}`} />)
98127
}
99128
</CoursesContainer>
100129
</CourseListContainer>

frontend/degree-plan/components/Search/SearchPanel.tsx

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import React, { createContext, useContext, useEffect } from "react";
2-
import useSWR from "swr";
1+
import React, { createContext, useContext, useEffect, useCallback } from "react";
2+
import useSWRInfinite from "swr/infinite";
33
import ResultsList from "./ResultsList";
44
import styled from "@emotion/styled";
55
import { DegreePlan, Rule, Fulfillment } from "@/types";
@@ -153,9 +153,16 @@ export const useDebounce = (value: any, delay: number) => {
153153
return debouncedValue;
154154
}
155155

156-
const buildSearchKey = (ruleId: Rule["id"] | null, query: string): string | null => {
157-
return query.length >= 3 || ruleId ? `api/base/all/search/courses?search=${query}${ruleId ? `&rule_ids=${ruleId}` : ""}` : null
158-
}
156+
const PAGE_SIZE = 50;
157+
158+
const buildSearchKey = (ruleId: Rule["id"] | null, query: string) =>
159+
(pageIndex: number, previousPageData: any): string | null => {
160+
if (!(query.length >= 3 || ruleId)) return null;
161+
// Stop fetching if we got fewer results than a full page
162+
if (previousPageData && !previousPageData.next) return null;
163+
const page = pageIndex + 1; // DRF pages are 1-indexed
164+
return `api/base/all/search/courses?search=${query}${ruleId ? `&rule_ids=${ruleId}` : ""}&page=${page}&page_size=${PAGE_SIZE}`;
165+
}
159166

160167
interface SearchResultsProps {
161168
ruleId: Rule["id"] | null,
@@ -166,16 +173,33 @@ interface SearchResultsProps {
166173
const SearchResults = ({ ruleId, query, activeDegreeplanId, fulfillments }: SearchResultsProps) => {
167174
const DISABLE_SEARCH = false
168175
const debouncedQuery = useDebounce(query, 400)
169-
const { data: courses = [], isLoading: isLoadingCourses, error } = useSWR(DISABLE_SEARCH ? null : buildSearchKey(ruleId, debouncedQuery));
176+
const { data, size, setSize, isLoading: isLoadingCourses, isValidating } = useSWRInfinite(
177+
DISABLE_SEARCH ? () => null : buildSearchKey(ruleId, debouncedQuery),
178+
{ revalidateFirstPage: false }
179+
);
180+
181+
const courses = data ? data.flatMap((page) => page.results ?? []) : [];
182+
const isLoadingMore = size > 1 && isValidating && data && typeof data[size - 1] === "undefined";
183+
const hasMore = data ? !!data[data.length - 1]?.next : false;
184+
185+
const loadMore = useCallback(() => {
186+
if (!isValidating && hasMore) {
187+
setSize((s) => s + 1);
188+
}
189+
}, [isValidating, hasMore, setSize]);
190+
170191
return (
171192
<>
172193
<SearchPanelResult>
173194
<ResultsList
174-
activeDegreeplanId={activeDegreeplanId}
175-
ruleId={ruleId}
195+
activeDegreeplanId={activeDegreeplanId}
196+
ruleId={ruleId}
176197
courses={courses}
177198
fulfillments={fulfillments}
178199
isLoading={isLoadingCourses}
200+
isLoadingMore={!!isLoadingMore}
201+
hasMore={hasMore}
202+
loadMore={loadMore}
179203
/>
180204
</SearchPanelResult>
181205
</>

frontend/degree-plan/tsconfig.tsbuildinfo

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)