Global search waits for selection before submitting.

This commit is contained in:
Roland 2024-01-01 15:16:03 -07:00
parent 377c69e8a7
commit 3ad7b8af00
No known key found for this signature in database
GPG key ID: 9D6DBD3ACF11AB93
9 changed files with 141 additions and 169 deletions

View file

@ -156,19 +156,34 @@ export function Getters() {
export function DashboardPage() {
const navigate = useNavigate();
// The search the user submitted.
const {searchStr, setSearchStr, activeTags} = useSearch();
// The search that the user is typing.
const [localSearchStr, setLocalSearchStr] = React.useState(searchStr);
console.log('searchStr', searchStr, 'localSearchStr', localSearchStr);
const {
suggestionsResults,
handleResultSelect,
resultRenderer,
loading,
setSearchStr: setSuggestionSearchStr
setSearchStr: setSuggestionSearchStr,
setSearchTags,
} = useContext(SearchSuggestionsContext);
const {status} = useContext(StatusContext);
React.useEffect(() => {
setSuggestionSearchStr(searchStr);
}, [searchStr])
setSuggestionSearchStr(localSearchStr);
}, [localSearchStr]);
React.useEffect(() => {
setLocalSearchStr(searchStr);
}, [searchStr]);
React.useEffect(() => {
if (activeTags) {
setSearchTags(activeTags);
}
}, [JSON.stringify(activeTags)]);
let title = 'Dashboard';
if (searchStr) {
@ -192,24 +207,28 @@ export function DashboardPage() {
body = <SearchView/>;
}
const getSearchResultsInput = (props) => {
return <SearchResultsInput clearable
searchStr={localSearchStr}
onChange={setLocalSearchStr}
onSubmit={setSearchStr}
placeholder='Search everywhere...'
onClear={() => navigate('/')}
style={{marginBottom: '2em'}}
results={suggestionsResults}
handleResultSelect={handleResultSelect}
resultRenderer={resultRenderer}
loading={loading}
{...props}
/>;
};
return <PageContainer>
<Media at='mobile'>
<Grid>
<Grid.Row columns={2}>
<Grid.Column width={13}>
<SearchResultsInput clearable
searchStr={searchStr}
onChange={setSearchStr}
onSubmit={setSearchStr}
size='large'
placeholder='Search everywhere...'
onClear={() => navigate('/')}
style={{marginBottom: '2em'}}
results={suggestionsResults}
handleResultSelect={handleResultSelect}
resultRenderer={resultRenderer}
loading={loading}
/>
{getSearchResultsInput({size: 'large'})}
</Grid.Column>
<Grid.Column width={3} textAlign='right'>
<FileSearchFilterButton size='large'/>
@ -221,19 +240,7 @@ export function DashboardPage() {
<Grid>
<Grid.Row columns={2}>
<Grid.Column mobile={14}>
<SearchResultsInput clearable
searchStr={searchStr}
onChange={setSearchStr}
onSubmit={setSearchStr}
size='big'
placeholder='Search everywhere...'
onClear={() => navigate('/')}
style={{marginBottom: '2em'}}
results={suggestionsResults}
handleResultSelect={handleResultSelect}
resultRenderer={resultRenderer}
loading={loading}
/>
{getSearchResultsInput({size: 'big'})}
</Grid.Column>
<Grid.Column textAlign='right' width={2}>
<FileSearchFilterButton size='big'/>

View file

@ -1003,36 +1003,23 @@ export async function fetchDecoded(vinNumber) {
}
}
export async function searchSuggestions(search_str) {
const body = {search_str: search_str};
export async function searchSuggestions(search_str, tagNames) {
const body = {search_str: search_str, tag_names: tagNames};
const response = await apiPost(`${API_URI}/search_suggestions`, body);
if (response.ok) {
const content = await response.json();
const fileGroups = content.file_groups;
const zimsEstimates = content.zims_estimates;
const channels = content.channels;
const domains = content.domains;
return {
fileGroups,
zimsEstimates,
channels,
domains,
}
}
}
export async function searchEstimateFiles(search_str, tagNames) {
const body = {search_str: search_str, tag_names: tagNames};
const response = await apiPost(`${API_URI}/files/search_estimates`, body);
if (response.ok) {
const content = await response.json();
return {
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);

View file

@ -1396,7 +1396,10 @@ export function TagIcon() {
}
export function normalizeEstimate(estimate) {
return estimate > 999 ? '>999' : estimate;
if (Number.isInteger(estimate)) {
return estimate > 999 ? '>999' : estimate.toString();
}
return '?';
}
export function useAPIButton(

View file

@ -3,7 +3,7 @@ import {createSearchParams, Route, Routes, useNavigate} from "react-router-dom";
import {FileSearchFilterButton, FilesSearchView} from "./Files";
import {useLatestRequest, usePages, useQuery} from "../hooks/customHooks";
import {ZimSearchView} from "./Zim";
import {searchEstimateFiles, searchEstimateZims, searchSuggestions} from "../api";
import {searchEstimateZims, searchSuggestions} from "../api";
import {fuzzyMatch, normalizeEstimate, SearchResultsInput, TabLinks} from "./Common";
import _ from "lodash";
import {TagsContext} from "../Tags";
@ -11,35 +11,28 @@ 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: {},
suggestions: {},
suggestionsSums: {},
handleResultSelect: null,
resultRenderer: null,
loading: false,
searchStr: '',
setSearchStr: null,
});
export const SearchSuggestionsProvider = (props) => {
const value = useSearchSuggestions();
return <SearchSuggestionsContext.Provider value={value}>
{props.children}
</SearchSuggestionsContext.Provider>
}
const SUGGESTED_APPS = [
{location: '/more/otp', title: 'One Time Pad', description: 'Encrypt and Decrypt messages'},
{location: '/inventory', title: 'Inventory', description: 'Track and organize your food storage'},
{location: '/more/vin', title: 'Vin Decoder', description: 'Decode and analyze vehicle VIN numbers'},
{location: '/admin', title: 'Downloads', description: 'View your downloads'},
{location: '/admin', title: 'Downloads', description: 'View and control your downloads'},
{location: '/admin/settings', title: 'Hotspot', description: 'Control your hotspot'},
{location: '/admin/settings', title: 'Restart', description: 'Restart your WROLPi'},
{location: '/admin/settings', title: 'Settings', description: 'View and modify your settings'},
{location: '/admin/settings', title: 'Shutdown', description: 'Shutdown your WROLPi'},
{location: '/admin/status', title: 'Status', description: 'View the status of this WROLPi server'},
{location: '/admin/wrol', title: 'WROL Mode', description: 'Enable or disable WROL Mode'},
{location: '/archive', title: 'Archives', description: 'View your Archives'},
{location: '/archive/domains', title: 'Domains', description: 'View the domains of your Archives'},
{location: '/files', title: 'Files', description: 'View your files'},
{location: '/help', title: 'Help', description: 'Help documents for WROLPi'},
{location: '/inventory', title: 'Inventory', description: 'Track and organize your food storage'},
{location: '/map', title: 'Map', description: 'View your Map'},
{location: '/map/manage', title: 'Manage Map', description: 'Manage your Map'},
{location: '/more/otp', title: 'One Time Pad', description: 'Encrypt and Decrypt messages'},
{location: '/more/statistics', title: 'Statistics', description: 'View the statistics of your WROLPi'},
{location: '/more/vin', title: 'Vin Decoder', description: 'Decode and analyze vehicle VIN numbers'},
{location: '/videos', title: 'Videos', description: 'View your Videos'},
{location: '/videos/channel', title: 'Channels', description: 'View the Channels of your Videos'},
{location: '/zim', title: 'Zim', description: 'View your Zims'},
{location: '/zim/manage', title: 'Manage Zim', description: 'Manage your Zims'},
];
export const useSearch = (defaultLimit = 48, totalPages = 0, emptySearch = false, model) => {
@ -101,27 +94,65 @@ export const useSearch = (defaultLimit = 48, totalPages = 0, emptySearch = false
}
}
export function useSuggestions(searchStr) {
const {data, sendRequest, loading} = useLatestRequest(500);
export const SearchSuggestionsContext = React.createContext({
suggestions: {}, // from the api
suggestionsResults: {}, // suggestions converted for <Search/> results.
suggestionsSums: {}, // suggestions from the api summarized.
handleResultSelect: null, // user clicks "result"
resultRenderer: null,
loading: false,
searchStr: '',
setSearchStr: null,
setSearchTags: null,
});
export function useSuggestions(searchStr, tagNames) {
const defaultSuggestions = {
fileGroups: [],
channels: [],
domains: [],
zimsEstimates: [],
}
const [suggestions, setSuggestions] = React.useState(defaultSuggestions);
// fileGroups/channels/domains.
const {data, sendRequest, loading} = useLatestRequest(500, true);
// Zims are slow, so they are separate.
const {data: zimData, sendRequest: sendZimRequest, loading: zimLoading} = useLatestRequest(500, true);
React.useEffect(() => {
if (!searchStr || searchStr.length === 0) {
return;
if ((searchStr && searchStr.length > 0) || (tagNames && tagNames.length > 0)) {
sendRequest(async () => await searchSuggestions(searchStr, tagNames));
sendZimRequest(async () => await searchEstimateZims(searchStr, tagNames));
}
}, [searchStr, sendRequest, sendZimRequest, JSON.stringify(tagNames)]);
// Use the `useLatestRequest` to handle user typing.
sendRequest(async () => await searchSuggestions(searchStr));
}, [searchStr, sendRequest]);
React.useEffect(() => {
if (!_.isEmpty(data)) {
setSuggestions({
...suggestions,
channels: data.channels,
fileGroups: data.fileGroups,
domains: data.domains,
});
}
}, [JSON.stringify(data)]);
return {suggestions: data, loading}
React.useEffect(() => {
if (!_.isEmpty(zimData)) {
setSuggestions({...suggestions, zimsEstimates: zimData.zimsEstimates});
}
}, [JSON.stringify(zimData)]);
return {suggestions, loading: loading || zimLoading}
}
export function useSearchSuggestions(defaultSearchStr) {
export function useSearchSuggestions(defaultSearchStr, defaultTagNames) {
const navigate = useNavigate();
const [searchStr, setSearchStr] = React.useState(defaultSearchStr || '');
const [searchTags, setSearchTags] = React.useState(defaultTagNames);
const {SingleTag, fuzzyMatchTagsByName} = React.useContext(TagsContext);
const {suggestions, loading} = useSuggestions(searchStr);
const {suggestions, loading} = useSuggestions(searchStr, searchTags);
// The results that will be displayed by <Search>.
const [suggestionsResults, setSuggestionsResults] = useState({});
@ -130,7 +161,7 @@ export function useSearchSuggestions(defaultSearchStr) {
const normalizeSuggestionsResults = (newSuggestions) => {
// Convert the suggestions from the Backend to what the Semantic <Search> expects.
const lowerSearchStr = searchStr.toLowerCase();
const lowerSearchStr = searchStr ? searchStr.toLowerCase() : '';
let results = {};
@ -147,8 +178,10 @@ export function useSearchSuggestions(defaultSearchStr) {
};
}
const zimSum = newSuggestions.zimsEstimates.reduce((i, j) => i + j, 0);
if (zimSum > 0) {
const zimSum = newSuggestions.zimsEstimates && newSuggestions.zimsEstimates.length > 0
? newSuggestions.zimsEstimates.reduce((i, j) => i + j.estimate, 0)
: null;
if (newSuggestions && zimSum > 0) {
results.zimsSum = {
name: 'Zims', results: [
{title: zimSum.toString(), type: 'zims', location: `/search/zim?q=${encodeURIComponent(searchStr)}`}
@ -176,7 +209,8 @@ export function useSearchSuggestions(defaultSearchStr) {
}
}
const matchingTags = fuzzyMatchTagsByName(searchStr);
// Match at most 5 Tags.
const matchingTags = searchStr ? fuzzyMatchTagsByName(searchStr).slice(0, 5) : null;
if (matchingTags && matchingTags.length > 0) {
results.tags = {
name: 'Tags', results: matchingTags.map(i => {
@ -189,7 +223,8 @@ export function useSearchSuggestions(defaultSearchStr) {
i.title.toLowerCase().includes(lowerSearchStr)
|| fuzzyMatch(i.title.toLowerCase(), lowerSearchStr));
if (matchingApps && matchingApps.length > 0) {
results.apps = {name: 'Apps', results: matchingApps};
// Match at most 5 apps.
results.apps = {name: 'Apps', results: matchingApps.slice(0, 5)};
}
setSuggestionsResults(results);
@ -198,8 +233,8 @@ export function useSearchSuggestions(defaultSearchStr) {
zims: zimSum,
channels: newSuggestions.channels.length,
domains: newSuggestions.domains.length,
tags: matchingTags.length,
apps: matchingApps.length,
tags: matchingTags?.length,
apps: matchingApps?.length,
});
}
@ -236,6 +271,7 @@ export function useSearchSuggestions(defaultSearchStr) {
suggestionsSums,
searchStr,
setSearchStr,
setSearchTags,
handleResultSelect,
resultRenderer,
loading
@ -243,67 +279,20 @@ export function useSearchSuggestions(defaultSearchStr) {
}
export function useSearchEstimate(searchStr, tagNames) {
const {data: filesData, sendRequest: sendFilesRequest, loading: loadingFiles} = useLatestRequest(250);
const {data: zimsData, sendRequest: sendZimsRequest, loading: loadingZims} = useLatestRequest(250);
export const SearchSuggestionsProvider = (props) => {
const value = useSearchSuggestions();
// The estimates from the response.
const [estimates, setEstimates] = React.useState({
fileGroups: [],
zims: [],
});
// The estimates summarized.
const [estimatesSums, setEstimatesSums] = React.useState({
fileGroups: 0,
zims: 0,
});
React.useEffect(() => {
// Handle results from `sendRequest`.
if (_.isEmpty(filesData)) {
return;
}
setEstimates({...estimates, fileGroups: filesData.fileGroups});
setEstimatesSums({...estimatesSums, fileGroups: filesData.fileGroups});
}, [JSON.stringify(filesData)]);
React.useEffect(() => {
sendFilesRequest(async () => await searchEstimateFiles(searchStr, tagNames));
}, [searchStr, JSON.stringify(tagNames)], sendFilesRequest);
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,
}
return <SearchSuggestionsContext.Provider value={value}>
{props.children}
</SearchSuggestionsContext.Provider>
}
export function SearchView() {
const {searchStr, activeTags} = useSearch();
const {estimates, estimatesSums, loading} = useSearchEstimate(searchStr, activeTags);
const {suggestions, suggestionsSums, loading} = useContext(SearchSuggestionsContext);
let filesTabName = <span>Files <Label>?</Label></span>;
let zimsTabName = <span>Zims<Label>?</Label></span>;
if (!_.isEmpty(estimatesSums)) {
filesTabName = <span>Files <Label>{normalizeEstimate(estimatesSums.fileGroups)}</Label></span>;
zimsTabName = <span>Zims <Label>{normalizeEstimate(estimatesSums.zims)}</Label></span>;
}
const filesTabName = <span>Files <Label>{normalizeEstimate(suggestionsSums?.fileGroups)}</Label></span>;
const zimsTabName = <span>Zims <Label>{normalizeEstimate(suggestionsSums?.zims)}</Label></span>;
const links = [
{text: filesTabName, to: '/search', key: 'filesSearch', end: true},
@ -314,7 +303,7 @@ export function SearchView() {
<TabLinks links={links}/>
<Routes>
<Route path='/*' element={<FilesSearchView/>}/>
<Route path='/zim' exact element={<ZimSearchView estimates={estimates} loading={loading}/>}/>
<Route path='/zim' exact element={<ZimSearchView suggestions={suggestions} loading={loading}/>}/>
</Routes>
</React.Fragment>
}

View file

@ -287,10 +287,10 @@ const ZimsRefreshWarning = () => {
</Message>;
}
export const ZimSearchView = ({estimates, loading}) => {
export const ZimSearchView = ({suggestions, loading}) => {
const [activeIndex, setActiveIndex] = React.useState(null);
const {searchStr, activeTags, setTags} = useSearch();
const {zimsEstimates} = estimates;
const {zimsEstimates} = suggestions;
const handleClick = (index, activeIndex_) => {
setActiveIndex(index === activeIndex_ ? -1 : index);
@ -308,7 +308,11 @@ export const ZimSearchView = ({estimates, loading}) => {
onClick={handleClick}
/>);
} else if (loading) {
body = <Loader active={true}/>;
body = <AccordionContent>
<Segment placeholder>
<Loader active={true}/>
</Segment>
</AccordionContent>;
} else if (_.isEmpty(zimsEstimates)) {
body = <ZimsRefreshWarning/>;
}

View file

@ -63,7 +63,7 @@ export const useRecurringTimeout = (callback, delay) => {
}, [delay])
}
export const useLatestRequest = (delay = 300) => {
export const useLatestRequest = (delay = 300, defaultLoading = false) => {
// A hook which ignores older requests and will only set `data` to the latest response's data.
// usage: sendRequest(async () => await yourAPICall(...args));
@ -72,7 +72,7 @@ export const useLatestRequest = (delay = 300) => {
const latestRequestRef = React.useRef(0);
const debounceTimerRef = React.useRef(null);
// Loading while awaiting.
const [loading, setLoading] = React.useState(false);
const [loading, setLoading] = React.useState(defaultLoading);
const sendRequest = React.useCallback((fetchFunction) => {
if (debounceTimerRef.current) {

View file

@ -509,25 +509,18 @@ async def post_shutdown(_: Request):
@api_bp.post('/search_suggestions')
@validate(json=schema.SearchSuggestionsRequest)
async def post_search_suggestions(_: Request, body: schema.SearchSuggestionsRequest):
"""Used by the Global search to suggest related Channels/Domains/etc. to the user.
@note: Does not handle Tags, unlike search_estimates"""
"""Used by the Global search to suggest related Channels/Domains/etc. to the user."""
from modules.videos.channel.lib import search_channels_by_name
from modules.archive.lib import search_domains_by_name
from modules.zim import lib as zim_lib
file_groups, channels, domains = await asyncio.gather(
estimate_search(body.search_str, []),
estimate_search(body.search_str, body.tag_names),
search_channels_by_name(body.search_str),
search_domains_by_name(body.search_str),
)
# Get estimates using libzim.
zims_estimates = zim_lib.get_estimates(body.search_str)
ret = dict(
file_groups=file_groups,
zims_estimates=zims_estimates,
channels=channels,
domains=domains,
)

View file

@ -140,9 +140,4 @@ class VINDecoderResponse:
@dataclass
class SearchSuggestionsRequest:
search_str: Optional[str] = None
@dataclass
class SearchEstimateRequest:
search_str: Optional[str] = None
tag_names: List[str] = field(default_factory=lambda: list())

View file

@ -468,15 +468,13 @@ async def test_post_vin_number_decoder(test_async_client):
@pytest.mark.asyncio
async def test_search_suggestions(test_session, test_async_client, channel_factory, archive_factory, tag_factory,
zim_factory):
async def test_search_suggestions(test_session, test_async_client, channel_factory, archive_factory, tag_factory):
# WARNING results are cached, this test uses unique queries to avoid conflicts.
channel_factory(name='Foo')
channel_factory(name='Fool')
channel_factory(name='Bar')
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='foo')
@ -489,8 +487,6 @@ async def test_search_suggestions(test_session, test_async_client, channel_facto
'url': 'https://example.com/Fool'}]
assert response.json['domains'] == [{'directory': 'archive/foo.com', 'domain': 'foo.com', 'id': 1}]
assert response.json['file_groups'] == 1
assert 'zims_estimates' in response.json and len(response.json['zims_estimates']) == 1
assert response.json['zims_estimates'][0] == 0
body = dict(search_str='bar')
request, response = await test_async_client.post('/api/search_suggestions', json=body)
@ -498,7 +494,6 @@ async def test_search_suggestions(test_session, test_async_client, channel_facto
assert response.json['channels'] == [{'directory': 'Bar', 'id': 3, 'name': 'Bar', 'url': 'https://example.com/Bar'}]
assert response.json['domains'] == [{'directory': 'archive/bar.com', 'domain': 'bar.com', 'id': 2}]
assert response.json['file_groups'] == 1
assert response.json['zims_estimates'][0] == 0
body = dict(search_str='one')
request, response = await test_async_client.post('/api/search_suggestions', json=body)
@ -506,7 +501,6 @@ async def test_search_suggestions(test_session, test_async_client, channel_facto
assert response.json['channels'] == []
assert response.json['domains'] == []
assert response.json['file_groups'] == 0
assert response.json['zims_estimates'][0] == 2
def test_recursive_errors():