From 8a1c9122d4c9dc708c79213fd0ad28bc99a163d0 Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:54:27 -0500 Subject: [PATCH 1/3] MOB-1343: ExploreV2 standard search submission --- .../ExploreV2/screens/UniversalSearch.tsx | 41 +++-- src/providers/ExploreV2Context.tsx | 2 +- .../ExploreV2/screens/UniversalSearch.test.js | 174 +++++++++++++++--- 3 files changed, 175 insertions(+), 42 deletions(-) diff --git a/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx b/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx index 227546353..cbeb97f87 100644 --- a/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx +++ b/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx @@ -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( null ); + const [selectedPlace, setSelectedPlace] = useState( 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.navigate( "ExploreResults" ); + }, [selectedSubject, selectedPlace, dispatch, navigation] ); const renderItem = useCallback>( ( { item } ) => { if ( item.type === "place" ) { diff --git a/src/providers/ExploreV2Context.tsx b/src/providers/ExploreV2Context.tsx index fba608288..cb6bfb958 100644 --- a/src/providers/ExploreV2Context.tsx +++ b/src/providers/ExploreV2Context.tsx @@ -25,7 +25,7 @@ export enum EXPLORE_V2_PLACE_MODE { PLACE = "PLACE" } -interface Place { +export interface Place { id: number; display_name?: string; } diff --git a/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js b/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js index 0c1e7507c..49b3f459d 100644 --- a/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js +++ b/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js @@ -179,7 +179,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 +191,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( ); + + 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 +224,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( ); - - 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 +321,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 +333,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 +347,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( ); + + pressSearch( ); + + expect( mockNavigate ).toHaveBeenCalledWith( "ExploreResults" ); + } ); + + it( "commits all organisms + worldwide when nothing is selected", ( ) => { + renderComponent( ); + + 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( ); + + 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( ); + + 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( ); + + 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( ); + + 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( ); + + pressSearch( ); + + expect( dismissSpy ).toHaveBeenCalled( ); + + dismissSpy.mockRestore( ); + } ); + } ); } ); From bda48e85ff24d27d99fa2784ce89ae43ea0a8782 Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:05:15 -0500 Subject: [PATCH 2/3] MOB-1343: popTo ExploreResults --- src/components/Explore/ExploreV2/screens/UniversalSearch.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx b/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx index cbeb97f87..e16ff0aa5 100644 --- a/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx +++ b/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx @@ -155,7 +155,7 @@ const UniversalSearch = ( ) => { ? { type: EXPLORE_V2_ACTION.SET_LOCATION_PLACE, place: selectedPlace } : { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE }, ); - navigation.navigate( "ExploreResults" ); + navigation.popTo( "ExploreResults" ); }, [selectedSubject, selectedPlace, dispatch, navigation] ); const renderItem = useCallback>( ( { item } ) => { From c56936712c3d5085cfeb8d8f1fd2078d154f4afe Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:19:39 -0500 Subject: [PATCH 3/3] MOB-1343: popto in tests --- .../Explore/ExploreV2/screens/UniversalSearch.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js b/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js index 49b3f459d..61fb7f96d 100644 --- a/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js +++ b/tests/unit/components/Explore/ExploreV2/screens/UniversalSearch.test.js @@ -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 ); @@ -379,7 +382,7 @@ describe( "UniversalSearch screen", ( ) => { pressSearch( ); - expect( mockNavigate ).toHaveBeenCalledWith( "ExploreResults" ); + expect( mockPopTo ).toHaveBeenCalledWith( "ExploreResults" ); } ); it( "commits all organisms + worldwide when nothing is selected", ( ) => {