Adding search icon button to navbar.

This commit is contained in:
Roland 2024-01-01 11:31:17 -07:00
parent 81419b8353
commit 377c69e8a7
No known key found for this signature in database
GPG key ID: 9D6DBD3ACF11AB93
13 changed files with 222 additions and 86 deletions

View file

@ -154,13 +154,22 @@ export function Getters() {
}
export function DashboardPage() {
const navigate = useNavigate();
const {searchStr, setSearchStr, activeTags} = useSearch();
const {suggestionsResults, handleResultSelect, resultRenderer, loading} = useContext(SearchSuggestionsContext);
const {
suggestionsResults,
handleResultSelect,
resultRenderer,
loading,
setSearchStr: setSuggestionSearchStr
} = useContext(SearchSuggestionsContext);
const {status} = useContext(StatusContext);
React.useEffect(() => {
setSuggestionSearchStr(searchStr);
}, [searchStr])
let title = 'Dashboard';
if (searchStr) {
title = `Search: ${searchStr} - Dashboard`;

View file

@ -1022,17 +1022,24 @@ export async function searchSuggestions(search_str) {
}
}
export async function searchEstimate(search_str, tagNames) {
export async function searchEstimateFiles(search_str, tagNames) {
const body = {search_str: search_str, tag_names: tagNames};
const response = await apiPost(`${API_URI}/search_estimates`, body);
const response = await apiPost(`${API_URI}/files/search_estimates`, body);
if (response.ok) {
const content = await response.json();
const fileGroups = content.file_groups;
const zimsEstimates = content.zims_estimates;
return {
fileGroups,
zimsEstimates,
fileGroups: content.file_groups,
}
}
}
export async function searchEstimateZims(search_str, tagNames) {
const body = {search_str: search_str, tag_names: tagNames};
const response = await apiPost(`${API_URI}/zim/search_estimates`, body);
if (response.ok) {
const content = await response.json();
return {
zimsEstimates: content.zims_estimates,
}
}
}

View file

@ -6,6 +6,7 @@ import Icon from "semantic-ui-react/dist/commonjs/elements/Icon";
import {CPUTemperatureIcon, DarkModeToggle, HotspotStatusIcon, NAME, SystemLoadIcon} from "./Common";
import {ShareButton} from "./Share";
import {useWROLMode} from "../hooks/customHooks";
import {SearchIconButton} from "./Search";
const links = [
{text: 'Videos', to: '/videos', key: 'videos'},
@ -76,6 +77,7 @@ export function NavBar() {
<NavIconWrapper><ShareButton/></NavIconWrapper>
<NavIconWrapper><HotspotStatusIcon/></NavIconWrapper>
<NavIconWrapper><DarkModeToggle/></NavIconWrapper>
<span style={{marginTop: '0.5em'}}><SearchIconButton/></span>
</React.Fragment>;
return <>

View file

@ -1,13 +1,15 @@
import React, {useState} from "react";
import React, {useContext, useState} from "react";
import {createSearchParams, Route, Routes, useNavigate} from "react-router-dom";
import {FilesSearchView} from "./Files";
import {FileSearchFilterButton, FilesSearchView} from "./Files";
import {useLatestRequest, usePages, useQuery} from "../hooks/customHooks";
import {ZimSearchView} from "./Zim";
import {searchEstimate, searchSuggestions} from "../api";
import {fuzzyMatch, normalizeEstimate, TabLinks} from "./Common";
import {searchEstimateFiles, searchEstimateZims, searchSuggestions} from "../api";
import {fuzzyMatch, normalizeEstimate, SearchResultsInput, TabLinks} from "./Common";
import _ from "lodash";
import {TagsContext} from "../Tags";
import {Header as SHeader, Label} from "semantic-ui-react";
import {Button, Modal, ModalContent} from "./Theme";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
export const SearchSuggestionsContext = React.createContext({
suggestionsResults: {},
@ -16,6 +18,8 @@ export const SearchSuggestionsContext = React.createContext({
handleResultSelect: null,
resultRenderer: null,
loading: false,
searchStr: '',
setSearchStr: null,
});
@ -113,10 +117,10 @@ export function useSuggestions(searchStr) {
}
export function useSearchSuggestions() {
export function useSearchSuggestions(defaultSearchStr) {
const navigate = useNavigate();
const [searchStr, setSearchStr] = React.useState(defaultSearchStr || '');
const {SingleTag, fuzzyMatchTagsByName} = React.useContext(TagsContext);
const {searchStr} = useSearch();
const {suggestions, loading} = useSuggestions(searchStr);
// The results that will be displayed by <Search>.
@ -226,43 +230,73 @@ export function useSearchSuggestions() {
return <span>{title}</span>
};
return {suggestions, suggestionsResults, suggestionsSums, searchStr, handleResultSelect, resultRenderer, loading}
return {
suggestions,
suggestionsResults,
suggestionsSums,
searchStr,
setSearchStr,
handleResultSelect,
resultRenderer,
loading
}
}
export function useSearchEstimate(searchStr, tagNames) {
const {data, sendRequest, loading} = useLatestRequest(250);
const {data: filesData, sendRequest: sendFilesRequest, loading: loadingFiles} = useLatestRequest(250);
const {data: zimsData, sendRequest: sendZimsRequest, loading: loadingZims} = useLatestRequest(250);
// The estimates from the response.
const [estimates, setEstimates] = React.useState({});
const [estimates, setEstimates] = React.useState({
fileGroups: [],
zims: [],
});
// The estimates summarized.
const [estimatesSums, setEstimatesSums] = React.useState({});
const [estimatesSums, setEstimatesSums] = React.useState({
fileGroups: 0,
zims: 0,
});
React.useEffect(() => {
// Handle results from `sendRequest`.
if (_.isEmpty(data)) {
if (_.isEmpty(filesData)) {
return;
}
setEstimates(data);
setEstimatesSums({
fileGroups: data.fileGroups,
zims: data.zimsEstimates.reduce((i, j) => i + j['estimate'], 0),
});
}, [JSON.stringify(data)]);
setEstimates({...estimates, fileGroups: filesData.fileGroups});
setEstimatesSums({...estimatesSums, fileGroups: filesData.fileGroups});
}, [JSON.stringify(filesData)]);
React.useEffect(() => {
// Use the `useLatestRequest` to handle user typing.
sendRequest(async () => await searchEstimate(searchStr, tagNames));
}, [searchStr, JSON.stringify(tagNames)], sendRequest);
sendFilesRequest(async () => await searchEstimateFiles(searchStr, tagNames));
}, [searchStr, JSON.stringify(tagNames)], sendFilesRequest);
return {estimates, estimatesSums, loading}
React.useEffect(() => {
// Handle results from `sendRequest`.
if (_.isEmpty(zimsData)) {
return;
}
setEstimates({...estimates, zimsEstimates: zimsData.zimsEstimates});
setEstimatesSums({...estimatesSums, zims: zimsData.zimsEstimates.reduce((i, j) => i + j['estimate'], 0)});
}, [JSON.stringify(zimsData)]);
React.useEffect(() => {
sendZimsRequest(async () => await searchEstimateZims(searchStr, tagNames));
}, [searchStr, JSON.stringify(tagNames)], sendZimsRequest);
return {
estimates,
estimatesSums,
loading: loadingFiles || loadingZims,
}
}
export function SearchView() {
const {searchStr, activeTags} = useSearch();
const {estimates, estimatesSums} = useSearchEstimate(searchStr, activeTags);
const {estimates, estimatesSums, loading} = useSearchEstimate(searchStr, activeTags);
let filesTabName = <span>Files <Label>?</Label></span>;
let zimsTabName = <span>Zims<Label>?</Label></span>;
@ -280,7 +314,54 @@ export function SearchView() {
<TabLinks links={links}/>
<Routes>
<Route path='/*' element={<FilesSearchView/>}/>
<Route path='/zim' exact element={<ZimSearchView estimates={estimates}/>}/>
<Route path='/zim' exact element={<ZimSearchView estimates={estimates} loading={loading}/>}/>
</Routes>
</React.Fragment>
}
export function SearchIconButton() {
// A single button which displays a modal for search suggestions.
const {
suggestionsResults,
handleResultSelect,
resultRenderer,
loading,
searchStr,
setSearchStr,
} = useContext(SearchSuggestionsContext);
const [open, setOpen] = React.useState(false);
const localHandleResultSelect = (i) => {
// Close modal when user selects a result.
setOpen(false);
handleResultSelect(i);
}
return <React.Fragment>
<Button icon='search' onClick={() => setOpen(!open)}/>
<Modal open={open} onClose={() => setOpen(false)} centered={false}>
<ModalContent>
<Grid columns={2}>
<Grid.Row>
<Grid.Column mobile={13} computer={14}>
<SearchResultsInput clearable
searchStr={searchStr}
onChange={setSearchStr}
onSubmit={setSearchStr}
size='large'
placeholder='Search everywhere...'
results={suggestionsResults}
handleResultSelect={localHandleResultSelect}
resultRenderer={resultRenderer}
loading={loading}
/>
</Grid.Column>
<Grid.Column mobile={3} computer={2}>
<FileSearchFilterButton/>
</Grid.Column>
</Grid.Row>
</Grid>
</ModalContent>
</Modal>
</React.Fragment>
}

View file

@ -5,6 +5,7 @@ import {
Divider,
Header,
Icon,
Loader,
Modal,
ModalActions,
ModalContent,
@ -286,7 +287,7 @@ const ZimsRefreshWarning = () => {
</Message>;
}
export const ZimSearchView = ({estimates}) => {
export const ZimSearchView = ({estimates, loading}) => {
const [activeIndex, setActiveIndex] = React.useState(null);
const {searchStr, activeTags, setTags} = useSearch();
const {zimsEstimates} = estimates;
@ -295,7 +296,7 @@ export const ZimSearchView = ({estimates}) => {
setActiveIndex(index === activeIndex_ ? -1 : index);
}
let body = <ZimsRefreshWarning/>;
let body;
if (!_.isEmpty(zimsEstimates)) {
body = zimsEstimates.map((i, index) => <ZimAccordion
key={i['path']}
@ -306,6 +307,10 @@ export const ZimSearchView = ({estimates}) => {
activeTags={activeTags}
onClick={handleClick}
/>);
} else if (loading) {
body = <Loader active={true}/>;
} else if (_.isEmpty(zimsEstimates)) {
body = <ZimsRefreshWarning/>;
}
return <>

View file

@ -12,6 +12,7 @@ from wrolpi.downloader import download_manager
from wrolpi.events import Events
from wrolpi.root_api import get_blueprint, json_response
from . import lib, schema
from .models import Zims
bp = get_blueprint('Zim', '/api/zim')
@ -158,3 +159,36 @@ async def delete_outdated_zims(_: Request):
lib.flag_outdated_zim_files()
Events.send_deleted(f'Deleted {deleted_count} outdated Zims')
return response.empty(HTTPStatus.NO_CONTENT)
@bp.post('/search_estimates')
@validate(json=schema.SearchEstimateRequest)
async def post_search_estimates(_: Request, body: schema.SearchEstimateRequest):
"""Get an estimated count of FileGroups/Zims which may or may not have been tagged."""
if not body.search_str and not body.tag_names:
return response.empty(HTTPStatus.BAD_REQUEST)
if body.tag_names:
# Get actual count of entries tagged with the tag names.
zims_estimates = list()
for zim, count in Zims.entries_with_tags(body.tag_names).items():
d = dict(
estimate=count,
**zim.__json__(),
)
zims_estimates.append(d)
else:
# Get estimates using libzim.
zims_estimates = list()
for zim, estimate in Zims.estimate(body.search_str).items():
d = dict(
estimate=estimate,
**zim.__json__(),
)
zims_estimates.append(d)
ret = dict(
zims_estimates=zims_estimates
)
return json_response(ret)

View file

@ -93,3 +93,9 @@ class ZimSubscribeRequest:
class OutdatedZims:
outdated: List[str]
current: List[str]
@dataclass
class SearchEstimateRequest:
search_str: Optional[str] = None
tag_names: List[str] = field(default_factory=lambda: list())

View file

@ -218,3 +218,15 @@ async def test_find_outdated_zim_files(test_session, test_directory, test_zim_by
assert wikipedia_two.is_file()
assert asdf.is_file()
assert empty.is_file()
@pytest.mark.asyncio
async def test_search_estimates(test_session, test_async_client, zim_factory):
zim_factory('test zim')
test_session.commit()
body = dict(search_str='one')
request, response = await test_async_client.post('/api/zim/search_estimates', json=body)
assert response.status_code == HTTPStatus.OK
assert len(response.json['zims_estimates']) == 1
assert response.json['zims_estimates'][0]['estimate'] == 2

View file

@ -372,3 +372,17 @@ async def post_upload(request: Request):
# Request the next chunk.
return json_response({'expected_chunk': expected_chunk_num}, HTTPStatus.OK)
@bp.post('/search_estimates')
@validate(json=schema.SearchEstimateRequest)
async def post_search_estimates(_: Request, body: schema.SearchEstimateRequest):
"""Get an estimated count of FileGroups which may or may not have been tagged."""
if not body.search_str and not body.tag_names:
return response.empty(HTTPStatus.BAD_REQUEST)
file_groups = await lib.estimate_search(body.search_str, body.tag_names)
ret = dict(
file_groups=file_groups,
)
return json_response(ret)

View file

@ -71,3 +71,9 @@ class Move:
class Rename:
path: str
new_name: str
@dataclass
class SearchEstimateRequest:
search_str: Optional[str] = None
tag_names: List[str] = field(default_factory=lambda: list())

View file

@ -585,3 +585,15 @@ async def test_delete_directory_recursive(test_session, test_directory, make_fil
empty = test_session.query(FileGroup).one()
assert empty.primary_path.name == 'empty'
@pytest.mark.asyncio
async def test_search_estimates(test_session, test_async_client, archive_factory):
archive_factory(domain='foo.com', contents='contents of foo')
archive_factory(domain='bar.com', contents='contents of bar')
test_session.commit()
body = dict(search_str='one')
request, response = await test_async_client.post('/api/files/search_estimates', json=body)
assert response.status_code == HTTPStatus.OK
assert response.json['file_groups'] == 0

View file

@ -534,43 +534,6 @@ async def post_search_suggestions(_: Request, body: schema.SearchSuggestionsRequ
return json_response(ret)
@api_bp.post('/search_estimates')
@validate(json=schema.SearchEstimateRequest)
async def post_search_estimates(_: Request, body: schema.SearchEstimateRequest):
"""Get an estimated count of FileGroups/Zims which may or may not have been tagged."""
from modules.zim.models import Zims
if not body.search_str and not body.tag_names:
return response.empty(HTTPStatus.BAD_REQUEST)
file_groups = await estimate_search(body.search_str, body.tag_names)
if body.tag_names:
# Get actual count of entries tagged with the tag names.
zims_estimates = list()
for zim, count in Zims.entries_with_tags(body.tag_names).items():
d = dict(
estimate=count,
**zim.__json__(),
)
zims_estimates.append(d)
else:
# Get estimates using libzim.
zims_estimates = list()
for zim, estimate in Zims.estimate(body.search_str).items():
d = dict(
estimate=estimate,
**zim.__json__(),
)
zims_estimates.append(d)
ret = dict(
file_groups=file_groups,
zims_estimates=zims_estimates
)
return json_response(ret)
class CustomJSONEncoder(json.JSONEncoder):
def default(self, obj):

View file

@ -509,21 +509,6 @@ async def test_search_suggestions(test_session, test_async_client, channel_facto
assert response.json['zims_estimates'][0] == 2
@pytest.mark.asyncio
async def test_search_estimates(test_session, test_async_client, archive_factory, zim_factory):
archive_factory(domain='foo.com', contents='contents of foo')
archive_factory(domain='bar.com', contents='contents of bar')
zim_factory('test zim')
test_session.commit()
body = dict(search_str='one')
request, response = await test_async_client.post('/api/search_estimates', json=body)
assert response.status_code == HTTPStatus.OK
assert response.json['file_groups'] == 0
assert len(response.json['zims_estimates']) == 1
assert response.json['zims_estimates'][0]['estimate'] == 2
def test_recursive_errors():
"""Errors are reported recursively."""