mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2026-08-02 02:17:30 -04:00
Merge pull request #3787 from inaturalist/mob-1341-search-state-hook
MOB-1341: extract search-field state into useSearchField hook
This commit is contained in:
@@ -23,7 +23,7 @@ import React, { useCallback, useRef, useState } from "react";
|
||||
import type { ListRenderItem, TextInput as RNTextInput } from "react-native";
|
||||
import { FlatList, Keyboard } from "react-native";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
import useDebouncedValue from "sharedHooks/useDebouncedValue";
|
||||
import useSearchField from "sharedHooks/useSearchField";
|
||||
import useTranslation from "sharedHooks/useTranslation";
|
||||
import type { UniversalSearchResultItem } from "sharedHooks/useUniversalSearch";
|
||||
import useUniversalSearch from "sharedHooks/useUniversalSearch";
|
||||
@@ -60,52 +60,33 @@ const UniversalSearch = ( ) => {
|
||||
const commonNameIsPrimary = currentUser?.prefers_common_names !== false
|
||||
&& currentUser?.prefers_scientific_name_first !== true;
|
||||
|
||||
const [subjectText, setSubjectText] = useState( "" );
|
||||
const [locationText, setLocationText] = useState( "" );
|
||||
const [filledFromSelection, setFilledFromSelection] = useState( false );
|
||||
// The debounced value that actually drives the autocomplete query. Cleared on
|
||||
// selection so a chosen suggestion doesn't re-trigger a result list.
|
||||
const {
|
||||
debouncedValue: debouncedQuery,
|
||||
debounce: debounceQuery,
|
||||
setImmediately: setQueryImmediately,
|
||||
} = useDebouncedValue( "" );
|
||||
text: subjectText,
|
||||
debouncedQuery: subjectQuery,
|
||||
hasQuery: subjectHasQuery,
|
||||
onChangeText: onChangeSubjectText,
|
||||
handleFocus: focusSubjectField,
|
||||
commit: commitSubject,
|
||||
clear: clearSubject,
|
||||
} = useSearchField( );
|
||||
const [locationText, setLocationText] = useState( "" );
|
||||
|
||||
const locationInputRef = useRef<RNTextInput>( null );
|
||||
|
||||
const { results, isLoading, refetch } = useUniversalSearch( debouncedQuery );
|
||||
const { results, isLoading, refetch } = useUniversalSearch( subjectQuery );
|
||||
|
||||
const bothFilled = subjectText.length > 0 && locationText.length > 0;
|
||||
|
||||
const hasQuery = debouncedQuery.trim( ).length > 0;
|
||||
|
||||
const handleSubjectTextChange = useCallback( ( text: string ) => {
|
||||
setSubjectText( text );
|
||||
setFilledFromSelection( false );
|
||||
debounceQuery( text );
|
||||
}, [debounceQuery] );
|
||||
|
||||
const handleSubjectFocus = useCallback( ( ) => {
|
||||
if ( !filledFromSelection ) { return; }
|
||||
setSubjectText( "" );
|
||||
setFilledFromSelection( false );
|
||||
setQueryImmediately( "" );
|
||||
}, [filledFromSelection, setQueryImmediately] );
|
||||
|
||||
const handleSelect = useCallback( ( subject: ExploreV2Subject ) => {
|
||||
setSubjectText( subjectToText( subject, commonNameIsPrimary ) );
|
||||
setFilledFromSelection( true );
|
||||
setQueryImmediately( "" );
|
||||
commitSubject( subjectToText( subject, commonNameIsPrimary ) );
|
||||
dispatch( { type: EXPLORE_V2_ACTION.SET_SUBJECT, subject } );
|
||||
locationInputRef.current?.focus( );
|
||||
}, [commonNameIsPrimary, dispatch, setQueryImmediately] );
|
||||
}, [commitSubject, commonNameIsPrimary, dispatch] );
|
||||
|
||||
const handleReset = useCallback( ( ) => {
|
||||
setSubjectText( "" );
|
||||
clearSubject( );
|
||||
setLocationText( "" );
|
||||
setFilledFromSelection( false );
|
||||
setQueryImmediately( "" );
|
||||
}, [setQueryImmediately] );
|
||||
}, [clearSubject] );
|
||||
|
||||
const handleSearch = useCallback( ( ) => {
|
||||
// TODO MOB-1338 follow-up: run the search (default to all organisms /
|
||||
@@ -139,8 +120,8 @@ const UniversalSearch = ( ) => {
|
||||
autoFocus
|
||||
className="flex-1 ml-2 text-md font-Lato-Regular"
|
||||
numberOfLines={1}
|
||||
onChangeText={handleSubjectTextChange}
|
||||
onFocus={handleSubjectFocus}
|
||||
onChangeText={onChangeSubjectText}
|
||||
onFocus={focusSubjectField}
|
||||
placeholder={t( "Search-for-species-user-or-project" )}
|
||||
placeholderTextColor={colors.mediumGray}
|
||||
testID="UniversalSearch.subjectInput"
|
||||
@@ -188,7 +169,7 @@ const UniversalSearch = ( ) => {
|
||||
<View className="flex-1">
|
||||
{/* Only surface results for an active query */}
|
||||
<FlatList
|
||||
data={hasQuery
|
||||
data={subjectHasQuery
|
||||
? results
|
||||
: []}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
@@ -197,7 +178,7 @@ const UniversalSearch = ( ) => {
|
||||
ListEmptyComponent={(
|
||||
<EmptySearchResults
|
||||
isLoading={isLoading}
|
||||
searchQuery={debouncedQuery}
|
||||
searchQuery={subjectQuery}
|
||||
refetch={refetch}
|
||||
/>
|
||||
)}
|
||||
|
||||
71
src/sharedHooks/useSearchField.ts
Normal file
71
src/sharedHooks/useSearchField.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import useDebouncedValue from "sharedHooks/useDebouncedValue";
|
||||
|
||||
export interface SearchField {
|
||||
// The controlled input value.
|
||||
text: string;
|
||||
// The debounced value that drives the autocomplete query. Cleared on selection
|
||||
// so a chosen suggestion doesn't re-trigger a result list.
|
||||
debouncedQuery: string;
|
||||
// Whether there's a non-empty query to surface results for.
|
||||
hasQuery: boolean;
|
||||
// Wire to the input's onChangeText.
|
||||
onChangeText: ( text: string ) => void;
|
||||
// Wire to the input's onFocus: clears a previously-committed value so the user
|
||||
// gets a fresh search when they tap back in.
|
||||
handleFocus: ( ) => void;
|
||||
// Fill the field from a chosen suggestion (without re-triggering a query).
|
||||
commit: ( text: string ) => void;
|
||||
// Clear the field entirely (e.g. on reset).
|
||||
clear: ( ) => void;
|
||||
}
|
||||
|
||||
// The shared state machine behind a debounced autocomplete search input: text +
|
||||
// debounced query + "fill on select, clear on re-focus". The Universal Search
|
||||
// subject field is an instance of this.
|
||||
const useSearchField = ( ): SearchField => {
|
||||
const [text, setText] = useState( "" );
|
||||
const [filledFromSelection, setFilledFromSelection] = useState( false );
|
||||
const {
|
||||
debouncedValue: debouncedQuery,
|
||||
debounce,
|
||||
setImmediately,
|
||||
} = useDebouncedValue( "" );
|
||||
|
||||
const onChangeText = useCallback( ( next: string ) => {
|
||||
setText( next );
|
||||
setFilledFromSelection( false );
|
||||
debounce( next );
|
||||
}, [debounce] );
|
||||
|
||||
const handleFocus = useCallback( ( ) => {
|
||||
if ( !filledFromSelection ) { return; }
|
||||
setText( "" );
|
||||
setFilledFromSelection( false );
|
||||
setImmediately( "" );
|
||||
}, [filledFromSelection, setImmediately] );
|
||||
|
||||
const commit = useCallback( ( next: string ) => {
|
||||
setText( next );
|
||||
setFilledFromSelection( true );
|
||||
setImmediately( "" );
|
||||
}, [setImmediately] );
|
||||
|
||||
const clear = useCallback( ( ) => {
|
||||
setText( "" );
|
||||
setFilledFromSelection( false );
|
||||
setImmediately( "" );
|
||||
}, [setImmediately] );
|
||||
|
||||
return {
|
||||
text,
|
||||
debouncedQuery,
|
||||
hasQuery: debouncedQuery.trim( ).length > 0,
|
||||
onChangeText,
|
||||
handleFocus,
|
||||
commit,
|
||||
clear,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSearchField;
|
||||
Reference in New Issue
Block a user