diff --git a/site/src/component/SearchHitContainer/SearchHitContainer.scss b/site/src/component/SearchHitContainer/SearchHitContainer.scss index eeb59f5d..2da81473 100644 --- a/site/src/component/SearchHitContainer/SearchHitContainer.scss +++ b/site/src/component/SearchHitContainer/SearchHitContainer.scss @@ -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; +} diff --git a/site/src/component/SearchHitContainer/SearchHitContainer.tsx b/site/src/component/SearchHitContainer/SearchHitContainer.tsx index a0c33478..d45ed1f6 100644 --- a/site/src/component/SearchHitContainer/SearchHitContainer.tsx +++ b/site/src/component/SearchHitContainer/SearchHitContainer.tsx @@ -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; ProfessorHitItem?: FC; } +const SearchResults = ({ + index, + results, + CourseHitItem, + ProfessorHitItem, +}: Required & { results: CourseGQLData[] | ProfessorGQLData[] }) => { + if (index === 'courses') { + return (results as CourseGQLData[]).map((course, i) => ); + } else { + return (results as ProfessorGQLData[]).map((professor, i) => ( + + )); + } +}; + const SearchHitContainer: FC = ({ 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(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 (
- {index == 'courses' && ( - <> - {courseResults.map((course, i) => { - return ; - })} - + {noResults && ( +
+ No results found + Sorry, we couldn't find any results for that search! +
)} - {index == 'professors' && ProfessorHitItem && ( - <> - {professorResults.map((professor, i) => { - return ; - })} - + {results.length > 0 && ( + )} +
+ +
); }; diff --git a/site/src/component/SearchModule/SearchModule.tsx b/site/src/component/SearchModule/SearchModule.tsx index 2405eb35..b7eb7f9c 100644 --- a/site/src/component/SearchModule/SearchModule.tsx +++ b/site/src/component/SearchModule/SearchModule.tsx @@ -1,17 +1,19 @@ -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; @@ -19,36 +21,22 @@ interface SearchModuleProps { const SearchModule: FC = ({ 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(null); + const [prevIndex, setPrevIndex] = useState(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') { @@ -63,29 +51,68 @@ const SearchModule: FC = ({ 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'; diff --git a/site/src/component/SearchPagination/SearchPagination.tsx b/site/src/component/SearchPagination/SearchPagination.tsx new file mode 100644 index 00000000..00e55eb2 --- /dev/null +++ b/site/src/component/SearchPagination/SearchPagination.tsx @@ -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 = ({ index }) => { + 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( + clickPageNumber(i)}> + {i + 1} + , + ); + } + + 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 : ( + + clickPageNumber(0)} disabled={activePage === 0} /> + clickPageNumber(activePage - 1)} disabled={activePage === 0} /> + {items} + clickPageNumber(activePage + 1)} disabled={activePage === numPages - 1} /> + + ) + ); +}; + +export default SearchPagination; diff --git a/site/src/helpers/constants.ts b/site/src/helpers/constants.ts new file mode 100644 index 00000000..c6403e41 --- /dev/null +++ b/site/src/helpers/constants.ts @@ -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; diff --git a/site/src/helpers/util.tsx b/site/src/helpers/util.tsx index f480f8b1..2ce32f5d 100644 --- a/site/src/helpers/util.tsx +++ b/site/src/helpers/util.tsx @@ -77,7 +77,6 @@ export async function searchAPIResults( transformed[key] = transformGQLData(index, data[id]); } } - console.log('From backend search', transformed); return transformed; } diff --git a/site/src/store/slices/searchSlice.ts b/site/src/store/slices/searchSlice.ts index 55aa280c..005358d9 100644 --- a/site/src/store/slices/searchSlice.ts +++ b/site/src/store/slices/searchSlice.ts @@ -5,6 +5,8 @@ interface SearchData { names: string[]; pageNumber: number; results: CourseGQLData[] | ProfessorGQLData[]; + hasFullResults: boolean; + lastQuery: string; } // Define a type for the slice state @@ -19,11 +21,15 @@ const initialState: SearchState = { names: [], pageNumber: 0, results: [], + hasFullResults: false, + lastQuery: '', }, professors: { names: [], pageNumber: 0, results: [], + hasFullResults: false, + lastQuery: '', }, }; @@ -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; diff --git a/site/src/style/theme.scss b/site/src/style/theme.scss index 40f27d77..c822925d 100644 --- a/site/src/style/theme.scss +++ b/site/src/style/theme.scss @@ -85,6 +85,27 @@ color: var(--text); text-shadow: none; } + + .page-link { + background-color: var(--overlay1); + border-color: var(--overlay2); + + &:hover, + &:focus { + background-color: var(--overlay2); + color: #1284ff; + } + } + + .page-item.active .page-link:hover { + background-color: #1284ff; + color: #fff; + } + + .page-item.disabled .page-link { + background-color: var(--overlay1); + border-color: var(--overlay2); + } } .popover-body {