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 all 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
20 changes: 20 additions & 0 deletions site/src/component/SearchHitContainer/SearchHitContainer.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,23 @@
padding-top: 2vh;
overflow-y: auto;
}

.no-results {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
font-size: 1.5rem;
padding: 2rem;
text-align: center;

img {
width: 400px;
max-width: 100%;
}
}

.search-pagination {
display: flex;
justify-content: center;
}
60 changes: 45 additions & 15 deletions site/src/component/SearchHitContainer/SearchHitContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,72 @@ import './SearchHitContainer.scss';
import { useAppSelector } from '../../store/hooks';

import { SearchIndex, CourseGQLData, ProfessorGQLData } from '../../types/types';
import SearchPagination from '../SearchPagination/SearchPagination';
import noResultsImg from '../../asset/no-results-crop.webp';
import { useFirstRender } from '../../hooks/firstRenderer';

// TODO: CourseHitItem and ProfessorHitem should not need index
// investigate: see if you can refactor respective components to use course id/ucinetid for keys instead then remove index from props
interface SearchHitContainerProps {
index: SearchIndex;
CourseHitItem: FC<CourseGQLData & { index: number }>;
ProfessorHitItem?: FC<ProfessorGQLData & { index: number }>;
}

const SearchResults = ({
index,
results,
CourseHitItem,
ProfessorHitItem,
}: Required<SearchHitContainerProps> & { results: CourseGQLData[] | ProfessorGQLData[] }) => {
if (index === 'courses') {
return (results as CourseGQLData[]).map((course, i) => <CourseHitItem key={course.id} index={i} {...course} />);
} else {
return (results as ProfessorGQLData[]).map((professor, i) => (
<ProfessorHitItem key={professor.ucinetid} index={i} {...professor} />
));
}
};

const SearchHitContainer: FC<SearchHitContainerProps> = ({ index, CourseHitItem, ProfessorHitItem }) => {
const courseResults = useAppSelector((state) => state.search.courses.results);
const professorResults = useAppSelector((state) => state.search.professors.results);
const { names, results } = useAppSelector((state) => state.search[index]);
const containerDivRef = useRef<HTMLDivElement>(null);
const isFirstRender = useFirstRender();

useEffect(() => {
containerDivRef.current!.scrollTop = 0;
}, [courseResults, professorResults]);
}, [results]);

if (index == 'professors' && !ProfessorHitItem) {
throw 'Professor Component not provided';
}

/**
* if its first render, we are waiting for initial results
* if names is non-empty but results is empty, we are waiting for results
* otherwise, if results is still empty, we have no results for the search
*/
const noResults = results.length === 0 && !(isFirstRender || names.length > 0);

return (
<div ref={containerDivRef} className="search-hit-container">
{index == 'courses' && (
<>
{courseResults.map((course, i) => {
return <CourseHitItem key={`course-hit-item-${i}`} index={i} {...(course as CourseGQLData)} />;
})}
</>
{noResults && (
<div className="no-results">
<img src={noResultsImg} alt="No results found" />
Sorry, we couldn't find any results for that search!
</div>
)}
{index == 'professors' && ProfessorHitItem && (
<>
{professorResults.map((professor, i) => {
return <ProfessorHitItem key={`professor-hit-item-${i}`} index={i} {...(professor as ProfessorGQLData)} />;
})}
</>
{results.length > 0 && (
<SearchResults
index={index}
results={results}
CourseHitItem={CourseHitItem}
ProfessorHitItem={ProfessorHitItem!}
/>
)}
<div className="search-pagination">
<SearchPagination index={index} />
</div>
</div>
);
};
Expand Down
117 changes: 72 additions & 45 deletions site/src/component/SearchModule/SearchModule.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,42 @@
import { FC, useEffect } from 'react';
import { Search } from 'react-bootstrap-icons';
import { useState, useEffect, FC, useCallback } from 'react';
import './SearchModule.scss';
import wfs from 'websoc-fuzzy-search';
import Form from 'react-bootstrap/Form';
import InputGroup from 'react-bootstrap/InputGroup';
import wfs from 'websoc-fuzzy-search';
import './SearchModule.scss';
import { Search } from 'react-bootstrap-icons';

import { searchAPIResults } from '../../helpers/util';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { setNames, setResults } from '../../store/slices/searchSlice';
import { setHasFullResults, setLastQuery, setNames, setPageNumber, setResults } from '../../store/slices/searchSlice';
import { searchAPIResults } from '../../helpers/util';
import { SearchIndex } from '../../types/types';
import { NUM_RESULTS_PER_PAGE } from '../../helpers/constants';

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

interface SearchModuleProps {
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;

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

// 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]);
const search = useAppSelector((state) => state.search[index]);
const [pendingRequest, setPendingRequest] = useState<NodeJS.Timeout | null>(null);
const [prevIndex, setPrevIndex] = useState<SearchIndex | null>(null);

const 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
*/
const searchNames = useCallback(
(query: string, pageNumber: number, lastQuery?: string) => {
// Get all results only when query changes or user reaches the fourth page or after
const nameResults = wfs({
query: query,
numResults: PAGE_SIZE * 5,
resultType: index === 'courses' ? 'COURSE' : 'INSTRUCTOR',
filterOptions: {},
// Load INITIAL_MAX_PAGE pages first
// when user reaches the 4th page or after, load all results
numResults:
lastQuery !== query || pageNumber < FULL_RESULT_THRESHOLD
? NUM_RESULTS_PER_PAGE * INITIAL_MAX_PAGE
: undefined,
});
let names: string[] = [];
if (index === 'courses') {
Expand All @@ -63,29 +51,68 @@ const SearchModule: FC<SearchModuleProps> = ({ index }) => {
).ucinetid,
) as string[];
}
console.log('From frontend search', names);
dispatch(setNames({ index, names }));
} catch (e) {
console.log(e);
}
};
// reset page number and hasFullResults flag if query changes
if (query !== lastQuery) {
dispatch(setPageNumber({ index, pageNumber: 0 }));
dispatch(setHasFullResults({ index, hasFullResults: false }));
dispatch(setLastQuery({ index, lastQuery: query }));
}
},
[dispatch, index],
);

// Search empty string to load some results on intial visit/when switching between courses and professors tabs
// make sure this runs before everything else for best performance and avoiding bugs
if (index !== prevIndex) {
setPrevIndex(index);
searchNames('', 0);
}

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

// clear results and reset page number when component unmounts
// results will persist otherwise, e.g. current page of results from catalogue carries over to roadmap search container
useEffect(() => {
return () => {
dispatch(setPageNumber({ index: 'courses', pageNumber: 0 }));
dispatch(setPageNumber({ index: 'professors', pageNumber: 0 }));
dispatch(setResults({ index: 'courses', results: [] }));
dispatch(setResults({ index: 'professors', results: [] }));
};
}, [dispatch]);

// Refresh search results when names and page number changes (controlled by searchResults dependency array)
useEffect(() => {
searchResults();
}, [index, searchResults]);

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

const coursePlaceholder = 'Search a course number or department';
Expand Down
51 changes: 51 additions & 0 deletions site/src/component/SearchPagination/SearchPagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { FC } from 'react';
import { Pagination } from 'react-bootstrap';
import { NUM_RESULTS_PER_PAGE } from '../../helpers/constants';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { setPageNumber } from '../../store/slices/searchSlice';
import { SearchIndex } from '../../types/types';

interface SearchPaginationProps {
index: SearchIndex;
}

/* SearchPagination is the page buttons at the bottom of the search results */
const SearchPagination: FC<SearchPaginationProps> = ({ index }) => {
aahei marked this conversation as resolved.
Show resolved Hide resolved
const dispatch = useAppDispatch();
const searchData = useAppSelector((state) => state.search[index]);

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

const numPages = Math.ceil(searchData.names.length / NUM_RESULTS_PER_PAGE);
const activePage = searchData.pageNumber;

// only show 5 page numbers at a time
const items = [];
let startPageNumber = Math.max(0, activePage - 2);
const 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 === activePage} onClick={() => clickPageNumber(i)}>
{i + 1}
</Pagination.Item>,
);
}

return (
// hide if there is no page or only one page
// last button intentionally left out since first 5 pages are fuzzy searched initially (we don't know what the last page # is)
numPages <= 1 ? null : (
<Pagination>
<Pagination.First onClick={() => clickPageNumber(0)} disabled={activePage === 0} />
<Pagination.Prev onClick={() => clickPageNumber(activePage - 1)} disabled={activePage === 0} />
{items}
<Pagination.Next onClick={() => clickPageNumber(activePage + 1)} disabled={activePage === numPages - 1} />
</Pagination>
)
);
};

export default SearchPagination;
4 changes: 4 additions & 0 deletions site/src/helpers/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// SearchPage
// Defined in the constants file because it is used in both
// SearchModule (the search bar) and SearchPagination (the pagination buttons)
export const NUM_RESULTS_PER_PAGE = 10;
1 change: 0 additions & 1 deletion site/src/helpers/util.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ export async function searchAPIResults(
transformed[key] = transformGQLData(index, data[id]);
}
}
console.log('From backend search', transformed);
return transformed;
}

Expand Down
17 changes: 16 additions & 1 deletion site/src/store/slices/searchSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ interface SearchData {
names: string[];
pageNumber: number;
results: CourseGQLData[] | ProfessorGQLData[];
hasFullResults: boolean;
lastQuery: string;
}

// Define a type for the slice state
Expand All @@ -19,11 +21,15 @@ const initialState: SearchState = {
names: [],
pageNumber: 0,
results: [],
hasFullResults: false,
lastQuery: '',
},
professors: {
names: [],
pageNumber: 0,
results: [],
hasFullResults: false,
lastQuery: '',
},
};

Expand All @@ -42,9 +48,18 @@ export const searchSlice = createSlice({
setResults: (state, action: PayloadAction<{ index: SearchIndex; results: SearchData['results'] }>) => {
state[action.payload.index].results = action.payload.results;
},
setHasFullResults: (
state,
action: PayloadAction<{ index: SearchIndex; hasFullResults: SearchData['hasFullResults'] }>,
) => {
state[action.payload.index].hasFullResults = action.payload.hasFullResults;
},
setLastQuery: (state, action: PayloadAction<{ index: SearchIndex; lastQuery: string }>) => {
state[action.payload.index].lastQuery = action.payload.lastQuery;
},
},
});

export const { setNames, setPageNumber, setResults } = searchSlice.actions;
export const { setNames, setPageNumber, setResults, setHasFullResults, setLastQuery } = searchSlice.actions;

export default searchSlice.reducer;
Loading
Loading