mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2026-08-02 10:27:34 -04:00
Merge branch 'mob-1343-universal-search-standard-search-submission' into mob-1332-species-tab
This commit is contained in:
@@ -25,7 +25,7 @@ import {
|
||||
View,
|
||||
} from "components/styledComponents";
|
||||
import type { ExploreStackScreenProps } from "navigation/types";
|
||||
import type { ExploreV2Subject } from "providers/ExploreV2Context";
|
||||
import type { ExploreV2Subject, Place } from "providers/ExploreV2Context";
|
||||
import { EXPLORE_V2_ACTION, useExploreV2 } from "providers/ExploreV2Context";
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import type { ListRenderItem, TextInput as RNTextInput } from "react-native";
|
||||
@@ -76,6 +76,10 @@ const UniversalSearch = ( ) => {
|
||||
// Which field's result list is showing. tracks the last-focused field rather
|
||||
// than live focus. Subject autofocuses, so it's the initial value.
|
||||
const [resultsField, setResultsField] = useState<"subject" | "location">( "subject" );
|
||||
|
||||
// What the user selected on this instance of the screen
|
||||
const [selectedSubject, setSelectedSubject] = useState<ExploreV2Subject | null>( null );
|
||||
const [selectedPlace, setSelectedPlace] = useState<Place | null>( null );
|
||||
const {
|
||||
text: subjectText,
|
||||
debouncedQuery: subjectQuery,
|
||||
@@ -117,31 +121,42 @@ const UniversalSearch = ( ) => {
|
||||
focusLocationField( );
|
||||
}, [focusLocationField] );
|
||||
|
||||
const handleSubjectSelect = useCallback( ( selectedSubject: ExploreV2Subject ) => {
|
||||
commitSubject( subjectToText( selectedSubject, commonNameIsPrimary ) );
|
||||
dispatch( { type: EXPLORE_V2_ACTION.SET_SUBJECT, subject: selectedSubject } );
|
||||
const handleSubjectSelect = useCallback( ( subject: ExploreV2Subject ) => {
|
||||
setSelectedSubject( subject );
|
||||
commitSubject( subjectToText( subject, commonNameIsPrimary ) );
|
||||
locationInputRef.current?.focus( );
|
||||
}, [commitSubject, commonNameIsPrimary, dispatch] );
|
||||
}, [commitSubject, commonNameIsPrimary] );
|
||||
|
||||
const handleLocationSelect = useCallback( ( place: LocationSearchResultItem ) => {
|
||||
setSelectedPlace( { id: place.id, display_name: place.display_name } );
|
||||
commitLocation( place.display_name );
|
||||
dispatch( {
|
||||
type: EXPLORE_V2_ACTION.SET_LOCATION_PLACE,
|
||||
place: { id: place.id, display_name: place.display_name },
|
||||
} );
|
||||
Keyboard.dismiss( );
|
||||
}, [commitLocation, dispatch] );
|
||||
}, [commitLocation] );
|
||||
|
||||
const handleReset = useCallback( ( ) => {
|
||||
clearSubject( );
|
||||
clearLocation( );
|
||||
setSelectedSubject( null );
|
||||
setSelectedPlace( null );
|
||||
}, [clearSubject, clearLocation] );
|
||||
|
||||
const handleSearch = useCallback( ( ) => {
|
||||
// TODO MOB-1338 follow-up: run the search (default to all organisms /
|
||||
// worldwide when a field is empty). Just dismiss the keyboard for now.
|
||||
Keyboard.dismiss( );
|
||||
}, [] );
|
||||
// Commit the composed search to context. Fields left unselected on
|
||||
// this screen fall back to their defaults: no subject → all organisms,
|
||||
// no location → worldwide.
|
||||
dispatch(
|
||||
selectedSubject
|
||||
? { type: EXPLORE_V2_ACTION.SET_SUBJECT, subject: selectedSubject }
|
||||
: { type: EXPLORE_V2_ACTION.CLEAR_SUBJECT },
|
||||
);
|
||||
dispatch(
|
||||
selectedPlace
|
||||
? { type: EXPLORE_V2_ACTION.SET_LOCATION_PLACE, place: selectedPlace }
|
||||
: { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE },
|
||||
);
|
||||
navigation.popTo( "ExploreResults" );
|
||||
}, [selectedSubject, selectedPlace, dispatch, navigation] );
|
||||
|
||||
const renderItem = useCallback<ListRenderItem<SearchResultItem>>( ( { item } ) => {
|
||||
if ( item.type === "place" ) {
|
||||
|
||||
@@ -28,7 +28,7 @@ export enum EXPLORE_V2_PLACE_MODE {
|
||||
PLACE = "PLACE"
|
||||
}
|
||||
|
||||
interface Place {
|
||||
export interface Place {
|
||||
id: number;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@ import { Keyboard } from "react-native";
|
||||
import { renderComponent } from "tests/helpers/render";
|
||||
|
||||
const mockNavigate = jest.fn( );
|
||||
const mockPopTo = jest.fn( );
|
||||
jest.mock( "@react-navigation/native", ( ) => {
|
||||
const actualNav = jest.requireActual( "@react-navigation/native" );
|
||||
return {
|
||||
...actualNav,
|
||||
useNavigation: ( ) => ( {
|
||||
navigate: mockNavigate,
|
||||
popTo: mockPopTo,
|
||||
goBack: jest.fn( ),
|
||||
canGoBack: ( ) => true,
|
||||
} ),
|
||||
@@ -118,6 +120,7 @@ beforeAll( async ( ) => {
|
||||
beforeEach( ( ) => {
|
||||
jest.useFakeTimers( );
|
||||
mockNavigate.mockClear( );
|
||||
mockPopTo.mockClear( );
|
||||
mockDispatch.mockClear( );
|
||||
useExploreV2.mockReturnValue( { dispatch: mockDispatch, state: {} } );
|
||||
useCurrentUser.mockReturnValue( CURRENT_USER );
|
||||
@@ -179,7 +182,7 @@ describe( "UniversalSearch screen", ( ) => {
|
||||
expect( screen.getByTestId( "UniversalSearchResult.info.7" ) ).toBeTruthy( );
|
||||
} );
|
||||
|
||||
it( "fills the field and sets the subject when a result is tapped", ( ) => {
|
||||
it( "fills the field but does not commit to context when a result is tapped", ( ) => {
|
||||
useUniversalSearch.mockReturnValue( {
|
||||
results: MIXED_RESULTS,
|
||||
isLoading: false,
|
||||
@@ -191,6 +194,30 @@ describe( "UniversalSearch screen", ( ) => {
|
||||
|
||||
fireEvent.press( screen.getByTestId( "UniversalSearchResult.user.7" ) );
|
||||
|
||||
// the selection is staged locally, not written to context until Search
|
||||
expect( mockDispatch ).not.toHaveBeenCalled( );
|
||||
// the search field is filled with the selected suggestion
|
||||
expect( screen.getByDisplayValue( "seth_msp" ) ).toBeTruthy( );
|
||||
} );
|
||||
|
||||
it( "clears the field but keeps the staged subject when refocusing after a selection", ( ) => {
|
||||
useUniversalSearch.mockReturnValue( {
|
||||
results: MIXED_RESULTS,
|
||||
isLoading: false,
|
||||
refetch: jest.fn( ),
|
||||
} );
|
||||
renderComponent( <UniversalSearch /> );
|
||||
|
||||
typeQuery( "ver" );
|
||||
fireEvent.press( screen.getByTestId( "UniversalSearchResult.user.7" ) );
|
||||
expect( screen.getByDisplayValue( "seth_msp" ) ).toBeTruthy( );
|
||||
|
||||
fireEvent( screen.getByTestId( "UniversalSearch.subjectInput" ), "focus" );
|
||||
|
||||
// the field is cleared for a fresh search...
|
||||
expect( screen.queryByDisplayValue( "seth_msp" ) ).toBeNull( );
|
||||
|
||||
fireEvent.press( screen.getByTestId( "UniversalSearch.searchButton" ) );
|
||||
expect( mockDispatch ).toHaveBeenCalledWith(
|
||||
expect.objectContaining( {
|
||||
type: "SET_SUBJECT",
|
||||
@@ -200,28 +227,6 @@ describe( "UniversalSearch screen", ( ) => {
|
||||
} ),
|
||||
} ),
|
||||
);
|
||||
// the search field is filled with the selected suggestion
|
||||
expect( screen.getByDisplayValue( "seth_msp" ) ).toBeTruthy( );
|
||||
} );
|
||||
|
||||
it( "clears the field but keeps the subject when tapping back in after a selection", ( ) => {
|
||||
useUniversalSearch.mockReturnValue( {
|
||||
results: MIXED_RESULTS,
|
||||
isLoading: false,
|
||||
refetch: jest.fn( ),
|
||||
} );
|
||||
renderComponent( <UniversalSearch /> );
|
||||
|
||||
typeQuery( "ver" );
|
||||
fireEvent.press( screen.getByTestId( "UniversalSearchResult.user.7" ) );
|
||||
expect( screen.getByDisplayValue( "seth_msp" ) ).toBeTruthy( );
|
||||
|
||||
mockDispatch.mockClear( );
|
||||
fireEvent( screen.getByTestId( "UniversalSearch.subjectInput" ), "focus" );
|
||||
|
||||
// the field is cleared for a fresh search...
|
||||
expect( screen.queryByDisplayValue( "seth_msp" ) ).toBeNull( );
|
||||
// ...but the committed subject persists (only Reset / a new selection clears it)
|
||||
expect( mockDispatch ).not.toHaveBeenCalledWith( { type: "CLEAR_SUBJECT" } );
|
||||
} );
|
||||
|
||||
@@ -319,7 +324,7 @@ describe( "UniversalSearch screen", ( ) => {
|
||||
expect( screen.queryByTestId( "LocationSearchResult.1" ) ).toBeNull( );
|
||||
} );
|
||||
|
||||
it( "fills the field, sets the place, and dismisses the keyboard on selection", ( ) => {
|
||||
it( "fills the field and dismisses the keyboard on place selection, without committing", ( ) => {
|
||||
const dismissSpy = jest.spyOn( Keyboard, "dismiss" ).mockImplementation( ( ) => {} );
|
||||
useLocationSearch.mockReturnValue( {
|
||||
results: PLACE_RESULTS,
|
||||
@@ -331,10 +336,6 @@ describe( "UniversalSearch screen", ( ) => {
|
||||
typeLocationQuery( "mon" );
|
||||
fireEvent.press( screen.getByTestId( "LocationSearchResult.1" ) );
|
||||
|
||||
expect( mockDispatch ).toHaveBeenCalledWith( {
|
||||
type: "SET_LOCATION_PLACE",
|
||||
place: { id: 1, display_name: "Monterey, CA, US" },
|
||||
} );
|
||||
// the location field is filled with the selected place
|
||||
expect( screen.getByDisplayValue( "Monterey, CA, US" ) ).toBeTruthy( );
|
||||
expect( dismissSpy ).toHaveBeenCalled( );
|
||||
@@ -349,4 +350,124 @@ describe( "UniversalSearch screen", ( ) => {
|
||||
|
||||
expect( mockNavigate ).toHaveBeenCalledWith( "AdvancedSearch" );
|
||||
} );
|
||||
|
||||
describe( "search submission", ( ) => {
|
||||
const selectSubject = ( ) => {
|
||||
useUniversalSearch.mockReturnValue( {
|
||||
results: MIXED_RESULTS,
|
||||
isLoading: false,
|
||||
refetch: jest.fn( ),
|
||||
} );
|
||||
typeQuery( "ver" );
|
||||
fireEvent.press( screen.getByTestId( "UniversalSearchResult.user.7" ) );
|
||||
};
|
||||
|
||||
// Stage a location pick on this instance of the screen.
|
||||
const selectPlace = ( ) => {
|
||||
useLocationSearch.mockReturnValue( {
|
||||
results: PLACE_RESULTS,
|
||||
isLoading: false,
|
||||
refetch: jest.fn( ),
|
||||
} );
|
||||
typeLocationQuery( "mon" );
|
||||
fireEvent.press( screen.getByTestId( "LocationSearchResult.1" ) );
|
||||
};
|
||||
|
||||
const pressSearch = ( ) => fireEvent.press(
|
||||
screen.getByTestId( "UniversalSearch.searchButton" ),
|
||||
);
|
||||
|
||||
it( "navigates to ExploreResults when the search button is pressed", ( ) => {
|
||||
renderComponent( <UniversalSearch /> );
|
||||
|
||||
pressSearch( );
|
||||
|
||||
expect( mockPopTo ).toHaveBeenCalledWith( "ExploreResults" );
|
||||
} );
|
||||
|
||||
it( "commits all organisms + worldwide when nothing is selected", ( ) => {
|
||||
renderComponent( <UniversalSearch /> );
|
||||
|
||||
pressSearch( );
|
||||
|
||||
expect( mockDispatch ).toHaveBeenCalledWith( { type: "CLEAR_SUBJECT" } );
|
||||
expect( mockDispatch ).toHaveBeenCalledWith( { type: "SET_LOCATION_WORLDWIDE" } );
|
||||
} );
|
||||
|
||||
it( "commits the subject + worldwide when only a subject is selected", ( ) => {
|
||||
renderComponent( <UniversalSearch /> );
|
||||
|
||||
selectSubject( );
|
||||
pressSearch( );
|
||||
|
||||
expect( mockDispatch ).toHaveBeenCalledWith(
|
||||
expect.objectContaining( {
|
||||
type: "SET_SUBJECT",
|
||||
subject: expect.objectContaining( {
|
||||
type: "user",
|
||||
user: expect.objectContaining( { id: 7 } ),
|
||||
} ),
|
||||
} ),
|
||||
);
|
||||
// location was left untouched → worldwide
|
||||
expect( mockDispatch ).toHaveBeenCalledWith( { type: "SET_LOCATION_WORLDWIDE" } );
|
||||
} );
|
||||
|
||||
it( "commits all organisms + the place when only a location is selected", ( ) => {
|
||||
renderComponent( <UniversalSearch /> );
|
||||
|
||||
selectPlace( );
|
||||
pressSearch( );
|
||||
|
||||
// subject was left untouched → all organisms
|
||||
expect( mockDispatch ).toHaveBeenCalledWith( { type: "CLEAR_SUBJECT" } );
|
||||
expect( mockDispatch ).toHaveBeenCalledWith( {
|
||||
type: "SET_LOCATION_PLACE",
|
||||
place: { id: 1, display_name: "Monterey, CA, US" },
|
||||
} );
|
||||
} );
|
||||
|
||||
it( "commits both when a subject and a location are selected", ( ) => {
|
||||
renderComponent( <UniversalSearch /> );
|
||||
|
||||
selectSubject( );
|
||||
selectPlace( );
|
||||
pressSearch( );
|
||||
|
||||
expect( mockDispatch ).toHaveBeenCalledWith(
|
||||
expect.objectContaining( { type: "SET_SUBJECT" } ),
|
||||
);
|
||||
expect( mockDispatch ).toHaveBeenCalledWith( {
|
||||
type: "SET_LOCATION_PLACE",
|
||||
place: { id: 1, display_name: "Monterey, CA, US" },
|
||||
} );
|
||||
expect( mockDispatch ).not.toHaveBeenCalledWith( { type: "CLEAR_SUBJECT" } );
|
||||
expect( mockDispatch ).not.toHaveBeenCalledWith( { type: "SET_LOCATION_WORLDWIDE" } );
|
||||
} );
|
||||
|
||||
it( "does not commit anything after Reset clears the staged selections", ( ) => {
|
||||
renderComponent( <UniversalSearch /> );
|
||||
|
||||
selectSubject( );
|
||||
selectPlace( );
|
||||
fireEvent.press( screen.getByText( i18next.t( "Reset-verb" ) ) );
|
||||
|
||||
pressSearch( );
|
||||
|
||||
// Reset cleared the staged picks, so Search falls back to the defaults.
|
||||
expect( mockDispatch ).toHaveBeenCalledWith( { type: "CLEAR_SUBJECT" } );
|
||||
expect( mockDispatch ).toHaveBeenCalledWith( { type: "SET_LOCATION_WORLDWIDE" } );
|
||||
} );
|
||||
|
||||
it( "dismisses the keyboard when the search button is pressed", ( ) => {
|
||||
const dismissSpy = jest.spyOn( Keyboard, "dismiss" ).mockImplementation( ( ) => {} );
|
||||
renderComponent( <UniversalSearch /> );
|
||||
|
||||
pressSearch( );
|
||||
|
||||
expect( dismissSpy ).toHaveBeenCalled( );
|
||||
|
||||
dismissSpy.mockRestore( );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
Reference in New Issue
Block a user