Merge branch 'mob-1343-universal-search-standard-search-submission' into mob-1332-species-tab

This commit is contained in:
sepeterson
2026-07-06 15:24:42 -05:00
3 changed files with 178 additions and 42 deletions

View File

@@ -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" ) {

View File

@@ -28,7 +28,7 @@ export enum EXPLORE_V2_PLACE_MODE {
PLACE = "PLACE"
}
interface Place {
export interface Place {
id: number;
display_name?: string;
}

View File

@@ -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( );
} );
} );
} );