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

Implement Search Pagination #297

Merged
merged 29 commits into from
Feb 13, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
5b111bc
search pagination with fixed page number
aahei May 12, 2023
891b859
pagination real max page number, but max = 5
aahei May 12, 2023
4c9ea6d
unlimited search result;
aahei May 12, 2023
8292c33
fix page number reset bug
aahei May 12, 2023
6fcca26
hide pagination when page num <= 1
aahei May 12, 2023
092bac1
add "no results found"
aahei May 12, 2023
8a4b81a
add index type assert in pagination
aahei May 12, 2023
326c455
remove assert; rewrite index condition
aahei May 12, 2023
3f87ac1
clean comment
aahei May 12, 2023
c4b90be
improve pagination performance by load only first 5 pages initially
aahei Jun 3, 2023
e72092d
format
aahei Jun 3, 2023
5e8e9aa
Merge branch 'master' of https://github.com/icssc/peterportal-client …
aahei Jun 3, 2023
012e10f
Added some comments
aahei Oct 21, 2023
c755c62
Merge branch 'search-pagination'
aahei Nov 30, 2023
9c3caaf
Merge branch 'master' into search-pagination
js0mmer Feb 1, 2024
b3cb733
Fix formatting + lint errors
js0mmer Feb 1, 2024
41974b1
Merge branch 'master' into search-pagination
js0mmer Feb 11, 2024
4e86ba4
Move some search states into slice to prevent infinite loop. Add deps…
js0mmer Feb 13, 2024
d2768a9
Squashed commit of the following:
js0mmer Feb 13, 2024
88afcc9
Decrease search timeout to 300ms
js0mmer Feb 13, 2024
78f5967
Add a comment
js0mmer Feb 13, 2024
2cd991a
Delete console logs for search
js0mmer Feb 13, 2024
2b6b4d6
Add another comment
js0mmer Feb 13, 2024
ab9aa28
Restore strict mode
js0mmer Feb 13, 2024
db6f4c5
Move search pagination css with hit container
js0mmer Feb 13, 2024
a0b86bc
Refactor no results logic
js0mmer Feb 13, 2024
ff4dc96
Restore useEffect for index switch
js0mmer Feb 13, 2024
775b8aa
Fix issues with current page persisting
js0mmer Feb 13, 2024
3b78b7f
Remove console log
js0mmer Feb 13, 2024
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
5 changes: 5 additions & 0 deletions site/src/component/SearchHitContainer/SearchHitContainer.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
.search-hit-container {
padding-top: 2vh;
overflow-y: auto;
}

.no-results {
padding: 2vh 2vh;
text-align: center;
}
10 changes: 10 additions & 0 deletions site/src/component/SearchHitContainer/SearchHitContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import './SearchHitContainer.scss';
import { useAppSelector } from '../../store/hooks';

import { SearchIndex, CourseGQLData, ProfessorGQLData } from '../../types/types';
import SearchPagination from '../SearchPagination/SearchPagination';

interface SearchHitContainerProps {
index: SearchIndex;
Expand All @@ -28,6 +29,9 @@ const SearchHitContainer: FC<SearchHitContainerProps> = ({ index, CourseHitItem,
{
index == 'courses' && <>
{
courseResults.length === 0 ?
<div className='no-results'>No results found</div>
aahei marked this conversation as resolved.
Show resolved Hide resolved
:
courseResults.map((course, i) => {
return <CourseHitItem key={`course-hit-item-${i}`} index={i} {...(course as CourseGQLData)} />
})
Expand All @@ -37,12 +41,18 @@ const SearchHitContainer: FC<SearchHitContainerProps> = ({ index, CourseHitItem,
{
(index == 'professors' && ProfessorHitItem) && <>
{
professorResults.length === 0 ?
<div className='no-results'>No results found</div>
aahei marked this conversation as resolved.
Show resolved Hide resolved
:
professorResults.map((professor, i) => {
return <ProfessorHitItem key={`professor-hit-item-${i}`} index={i} {...(professor as ProfessorGQLData)} />
})
}
</>
}
<div className='search-pagination'>
<SearchPagination index={index} />
</div>
</div>
}

Expand Down
6 changes: 6 additions & 0 deletions site/src/component/SearchModule/SearchModule.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
.search-bar {
border-color: #80bdff;
padding: 2vh 2vh;
}

// center the search pagination
.search-pagination {
display: flex;
justify-content: center;
}
216 changes: 129 additions & 87 deletions site/src/component/SearchModule/SearchModule.tsx
Original file line number Diff line number Diff line change
@@ -1,107 +1,149 @@
import React, { useState, useEffect, Component, FC } from 'react';
import './SearchModule.scss';
import wfs from 'websoc-fuzzy-search';
import axios from 'axios';
import Form from 'react-bootstrap/Form';
import InputGroup from 'react-bootstrap/InputGroup';
import { Search } from 'react-bootstrap-icons';
import React, { useState, useEffect, Component, FC } from "react";
import "./SearchModule.scss";
import wfs from "websoc-fuzzy-search";
import axios from "axios";
import Form from "react-bootstrap/Form";
import InputGroup from "react-bootstrap/InputGroup";
import { Search } from "react-bootstrap-icons";

import { useAppDispatch, useAppSelector } from "../../store/hooks";
import {
setNames,
setPageNumber,
setResults,
} from "../../store/slices/searchSlice";
import { searchAPIResults } from "../../helpers/util";
import {
SearchIndex,
BatchCourseData,
CourseGQLResponse,
ProfessorGQLResponse,
BatchProfessorData,
} from "../../types/types";
import { PAGE_SIZE } from "src/helpers/constants";

import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { setNames, setResults } from '../../store/slices/searchSlice';
import { searchAPIResults } from '../../helpers/util';
import { SearchIndex, BatchCourseData, CourseGQLResponse, ProfessorGQLResponse, BatchProfessorData } from '../../types/types';

const PAGE_SIZE = 10;
const SEARCH_TIMEOUT_MS = 500;
const FULL_RESULT_THRESHOLD = 3;
const INITIAL_MAX_PAGE = 5;

interface SearchModuleProps {
index: SearchIndex;
index: SearchIndex;
}

const SearchModule: FC<SearchModuleProps> = ({ index }) => {
const dispatch = useAppDispatch();
const courseSearch = useAppSelector(state => state.search.courses);
const professorSearch = useAppSelector(state => state.search.professors);
let pendingRequest: NodeJS.Timeout | null = null;
const dispatch = useAppDispatch();
const courseSearch = useAppSelector((state) => state.search.courses);
const professorSearch = useAppSelector((state) => state.search.professors);
const [hasFullResults, setHasFullResults] = useState(false);
const [lastQuery, setLastQuery] = useState("");
let pendingRequest: NodeJS.Timeout | null = null;

// Search empty string to load some results
useEffect(() => {
searchNames('');
}, [])
// Search empty string to load some results
useEffect(() => {
searchNames("");
}, []);

// Refresh search results when names and page number changes
useEffect(() => {
searchResults('courses', courseSearch.pageNumber, courseSearch.names);
}, [courseSearch.names, courseSearch.pageNumber])
useEffect(() => {
searchResults('professors', professorSearch.pageNumber, professorSearch.names);
}, [professorSearch.names, professorSearch.pageNumber])
// Refresh search results when names and page number changes
useEffect(() => {
searchResults("courses", courseSearch.pageNumber, courseSearch.names);
}, [courseSearch.names, courseSearch.pageNumber]);
useEffect(() => {
searchResults(
"professors",
professorSearch.pageNumber,
professorSearch.names
);
}, [professorSearch.names, professorSearch.pageNumber]);

let searchNames = (query: string) => {
try {
/*
TODO: Search optimization
- Currently sending a query request for every input change
- Goal is to have only one query request pending
- Use setTimeout/clearTimeout to keep track of pending query request
*/
let nameResults = wfs({
query: query,
numResults: PAGE_SIZE * 5,
resultType: index === 'courses' ? 'COURSE' : 'INSTRUCTOR',
filterOptions: {
}
})
let names: string[] = [];
if (index == 'courses') {
names = Object.keys(nameResults);
}
else if (index == 'professors') {
names = Object.keys(nameResults).map(n => nameResults[n].metadata.ucinetid) as string[];
}
console.log('From frontend search', names)
dispatch(setNames({ index, names }));
}
catch (e) {
console.log(e)
}
let searchNames = (query: string) => {
try {
// Get all results only when query changes or user reaches the fourth page or after
const currentPage =
index === "courses"
? courseSearch.pageNumber
: professorSearch.pageNumber;
let nameResults = wfs({
query: query,
resultType: index === "courses" ? "COURSE" : "INSTRUCTOR",
numResults:
lastQuery !== query || currentPage < FULL_RESULT_THRESHOLD
? PAGE_SIZE * INITIAL_MAX_PAGE
: undefined,
});
let names: string[] = [];
if (index == "courses") {
names = Object.keys(nameResults);
} else if (index == "professors") {
names = Object.keys(nameResults).map(
(n) => nameResults[n].metadata.ucinetid
) as string[];
}
console.log("From frontend search", names);
dispatch(setNames({ index, names }));
// reset page number and hasFullResults flag if query changes
if (query !== lastQuery) {
dispatch(setPageNumber({ index, pageNumber: 0 }));
setHasFullResults(false);
setLastQuery(query);
}
} catch (e) {
console.log(e);
}
};

let searchResults = async (index: SearchIndex, pageNumber: number, names: string[]) => {
// Get the subset of names based on the page
let pageNames = names.slice(PAGE_SIZE * pageNumber, PAGE_SIZE * (pageNumber + 1))
let results = await searchAPIResults(index, pageNames);
dispatch(setResults({ index, results: Object.values(results) }));
let searchResults = async (
index: SearchIndex,
pageNumber: number,
names: string[]
) => {
if (!hasFullResults && pageNumber >= FULL_RESULT_THRESHOLD) {
setHasFullResults(true);
searchNames(lastQuery);
}
// Get the subset of names based on the page
let pageNames = names.slice(
PAGE_SIZE * pageNumber,
PAGE_SIZE * (pageNumber + 1)
);
let results = await searchAPIResults(index, pageNames);
dispatch(setResults({ index, results: Object.values(results) }));
};

let searchNamesAfterTimeout = (query: string) => {
if (pendingRequest) {
clearTimeout(pendingRequest);
}
let timeout = setTimeout(() => {
searchNames(query);
pendingRequest = null;
}, SEARCH_TIMEOUT_MS);
pendingRequest = timeout;
let searchNamesAfterTimeout = (query: string) => {
if (pendingRequest) {
clearTimeout(pendingRequest);
}
let timeout = setTimeout(() => {
searchNames(query);
pendingRequest = null;
}, SEARCH_TIMEOUT_MS);
pendingRequest = timeout;
};

let coursePlaceholder = 'Search a course number or department';
let professorPlaceholder = 'Search a professor';
let placeholder = index == 'courses' ? coursePlaceholder : professorPlaceholder;
let coursePlaceholder = "Search a course number or department";
let professorPlaceholder = "Search a professor";
let placeholder =
index == "courses" ? coursePlaceholder : professorPlaceholder;

return <div className='search-module'>
<Form.Group className="mb-3">
<InputGroup>
<InputGroup.Prepend>
<InputGroup.Text>
<Search />
</InputGroup.Text>
</InputGroup.Prepend>
<Form.Control className='search-bar' type="text" placeholder={placeholder} onChange={(e) => searchNamesAfterTimeout(e.target.value)} />
</InputGroup>
</Form.Group>
return (
<div className="search-module">
<Form.Group className="mb-3">
<InputGroup>
<InputGroup.Prepend>
<InputGroup.Text>
<Search />
</InputGroup.Text>
</InputGroup.Prepend>
<Form.Control
className="search-bar"
type="text"
placeholder={placeholder}
onChange={(e) => searchNamesAfterTimeout(e.target.value)}
/>
</InputGroup>
</Form.Group>
</div>
}
);
};

export default SearchModule;
export default SearchModule;
58 changes: 58 additions & 0 deletions site/src/component/SearchPagination/SearchPagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { FC, useEffect } from "react";
import { Pagination } from "react-bootstrap";
import { PAGE_SIZE } from "src/helpers/constants";
import { useAppDispatch, useAppSelector } from "src/store/hooks";
import { setPageNumber } from "src/store/slices/searchSlice";
import { SearchIndex } from "src/types/types";

interface SearchPaginationProps {
index: SearchIndex;
}

const SearchPagination: FC<SearchPaginationProps> = ({ index }) => {
aahei marked this conversation as resolved.
Show resolved Hide resolved
const dispatch = useAppDispatch();
const coursePageNumber = useAppSelector(state => state.search.courses.pageNumber);
const professorPageNumber = useAppSelector(state => state.search.professors.pageNumber);

const courseData = useAppSelector(state => state.search.courses);
const professorData = useAppSelector(state => state.search.professors);

const clickPageNumber = (pageNumber: number) => {
dispatch(setPageNumber({index, pageNumber}));
}

let numPages = 0;
let active = 0;
if (index === 'courses') {
numPages = Math.ceil(courseData.names.length / PAGE_SIZE);
active = coursePageNumber;
} else if (index === 'professors') {
numPages = Math.ceil(professorData.names.length / PAGE_SIZE);
active = professorPageNumber;
}

let items = [];
let startPageNumber = Math.max(0, active - 2);
let endPageNumber = Math.min(numPages, startPageNumber + 5); // exclusive
startPageNumber = Math.max(0, endPageNumber - 5);
for (let i = startPageNumber; i < endPageNumber; i++) {
items.push(
<Pagination.Item key={i} active={i === active} onClick={() => clickPageNumber(i)}>
{i + 1}
</Pagination.Item>
);
}

return (
// hide if there is no page or only one page
numPages <= 1 ? null :
<Pagination>
<Pagination.First onClick={() => clickPageNumber(0)} disabled={active === 0} />
<Pagination.Prev onClick={() => clickPageNumber(active - 1)} disabled={active === 0} />
{items}
<Pagination.Next onClick={() => clickPageNumber(active + 1)} disabled={active === numPages - 1} />
</Pagination>
);
}

export default SearchPagination;
2 changes: 2 additions & 0 deletions site/src/helpers/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// SearchPage
export const PAGE_SIZE = 10;
aahei marked this conversation as resolved.
Show resolved Hide resolved