From dcd0974ad263afdb7d87dba1ec539423fd4d2cdc Mon Sep 17 00:00:00 2001 From: Nicolas Harraudeau <40498978+nicolas-harraudeau-sonarsource@users.noreply.github.com> Date: Thu, 2 Jul 2020 23:55:56 +0200 Subject: [PATCH] Paginate search results. closes #16 --- frontend/package-lock.json | 12 ++++++++ frontend/package.json | 1 + frontend/src/SearchPage.js | 35 ++++++++++++++++++---- frontend/src/utils/routing.js | 53 +++++++++++++++++++++++++++++++++ frontend/src/utils/useSearch.js | 26 ++++++++++++---- 5 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 frontend/src/utils/routing.js diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 210fc85bcb..e28430110d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1383,6 +1383,18 @@ "@babel/runtime": "^7.4.4" } }, + "@material-ui/lab": { + "version": "4.0.0-alpha.56", + "resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.56.tgz", + "integrity": "sha512-xPlkK+z/6y/24ka4gVJgwPfoCF4RCh8dXb1BNE7MtF9bXEBLN/lBxNTK8VAa0qm3V2oinA6xtUIdcRh0aeRtVw==", + "requires": { + "@babel/runtime": "^7.4.4", + "@material-ui/utils": "^4.10.2", + "clsx": "^1.0.4", + "prop-types": "^15.7.2", + "react-is": "^16.8.0" + } + }, "@material-ui/styles": { "version": "4.10.0", "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.10.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7d4c425ed7..2a5594bca8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "dependencies": { "@material-ui/core": "^4.10.2", "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "^4.0.0-alpha.56", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.5.0", "@testing-library/user-event": "^7.2.1", diff --git a/frontend/src/SearchPage.js b/frontend/src/SearchPage.js index 6a94342f24..9829eae03c 100644 --- a/frontend/src/SearchPage.js +++ b/frontend/src/SearchPage.js @@ -5,8 +5,13 @@ import Paper from '@material-ui/core/Paper'; import Typography from '@material-ui/core/Typography'; import TextField from '@material-ui/core/TextField'; import Container from '@material-ui/core/Container'; +import Pagination from '@material-ui/lab/Pagination'; import { useSearch } from './utils/useSearch'; +import { + useLocationSearch, + useLocationSearchState +} from './utils/routing'; import { SearchHit } from './SearchHit'; @@ -22,11 +27,15 @@ const classes = makeStyles((theme) => ({ }, })); - export const SearchPage = () => { - const [titleQuery, setTitleQuery] = useState(""); + const pageSize = 20; + const [query, setQuery] = useLocationSearchState('query', ''); + const [pageNumber, setPageNumber] = useLocationSearchState('page', 1, parseInt); + const [_, setLocationSearch] = useLocationSearch(); - const [results, resultsAreLoading] = useSearch(titleQuery); + + const [results, numberOfHits, error, resultsAreLoading] = useSearch(query, pageSize, pageNumber); + const totalPages = Math.ceil(numberOfHits/pageSize); let resultsDisplay="No rule found..."; if (resultsAreLoading) { @@ -35,6 +44,14 @@ export const SearchPage = () => { else if (results.length > 0) { resultsDisplay = results.map(result => ) } + + function handleQueryUpdate(event) { + if (pageNumber > 1) { + setLocationSearch({query: event.target.value, page: 1}); + } else { + setQuery(event.target.value, {push: false}); + } + } return (
@@ -52,15 +69,21 @@ export const SearchPage = () => { shrink: true, }} variant="outlined" - value={titleQuery} - onChange={e => setTitleQuery(e.target.value)} + value={query} + onChange={handleQueryUpdate} + error={error} + helperText={error} /> -

Results

+ Number of rules found: {numberOfHits} + setPageNumber(value)} + /> +
) } \ No newline at end of file diff --git a/frontend/src/utils/routing.js b/frontend/src/utils/routing.js new file mode 100644 index 0000000000..483975d95c --- /dev/null +++ b/frontend/src/utils/routing.js @@ -0,0 +1,53 @@ +import React, { useState } from "react"; +import { useLocation, useHistory } from "react-router-dom"; + + +export function useLocationSearch() { + const location = useLocation(); + const history = useHistory(); + + function setLocationSearch(searchParams, push=true) { + const search = new URLSearchParams(location.search); + + for (const [key, value] of Object.entries(searchParams)) { + search.set(key, value); + } + + if (push) { + history.push(`${location.pathname}?${search.toString()}`); + } else { + history.replace(`${location.pathname}?${search.toString()}`); + } + } + + return [new URLSearchParams(location.search), setLocationSearch]; +} + +export function useLocationSearchState(name, defaultValue, convert=value=>value) { + const [state, setState] = useState(defaultValue); + const location = useLocation(); + const history = useHistory(); + + React.useEffect(() => { + const search = new URLSearchParams(location.search); + if (search.has(name) && search.get(name) != state) { + setState(convert(search.get(name))); + } else if (!search.has(name) && state != defaultValue) { + setState(defaultValue); + } + }, [location, history]); + + function setSearchParam(value, {push=true, skipURI=false} = {}) { + const search = new URLSearchParams(location.search); + + search.set(name, value); + setState(value); + if (push) { + history.push(`${location.pathname}?${search.toString()}`); + } else { + history.replace(`${location.pathname}?${search.toString()}`); + } + } + + return [state, setSearchParam]; +} \ No newline at end of file diff --git a/frontend/src/utils/useSearch.js b/frontend/src/utils/useSearch.js index 0a283b50ef..65a5884834 100644 --- a/frontend/src/utils/useSearch.js +++ b/frontend/src/utils/useSearch.js @@ -4,7 +4,7 @@ import * as lunr from 'lunr' import { useFetch } from './useFetch'; -export function useSearch(query) { +export function useSearch(query, pageSize, pageNumber) { let indexDataUrl = `${process.env.PUBLIC_URL}/rules/rule-index.json`; let storeDataUrl = `${process.env.PUBLIC_URL}/rules/rule-index-store.json`; @@ -12,6 +12,8 @@ export function useSearch(query) { const [storeData, storeDataError, storeDataIsLoading] = useFetch(storeDataUrl); const [results, setResults] = useState([]); + const [numberOfHits, setNumberOfHits] = useState(null); + const [error, setError] = useState(null); const [resultsAreloading, setResultsAreLoading] = useState(true); React.useEffect(() => { @@ -23,11 +25,25 @@ export function useSearch(query) { if (query) { finalQuery = `titles:${query}` } - const hits = index.search(finalQuery); - setResults(hits.map(({ ref }) => storeData[ref])); + + let hits = [] + setError(null); + try { + hits = index.search(finalQuery); + } catch (exception) { + if (exception instanceof lunr.QueryParseError) { + setError(exception.message); + } else { + throw exception; + } + } + setNumberOfHits(hits.length) + // const pageResults = hits; + const pageResults = hits.slice(pageSize*(pageNumber - 1), pageSize*(pageNumber)); + setResults(pageResults.map(({ ref }) => storeData[ref])); setResultsAreLoading(false); } - }, [query, indexData, storeData, indexDataError, storeDataError, indexDataIsLoading, storeDataIsLoading]); + }, [query, pageSize, pageNumber, error, indexData, storeData, indexDataError, storeDataError, indexDataIsLoading, storeDataIsLoading]); - return [results, resultsAreloading]; + return [results, numberOfHits, error, resultsAreloading]; } \ No newline at end of file