Merge pull request #3771 from inaturalist/mob-1462-search-myobs-ui-2

Mob 1462 search myobs UI 2
This commit is contained in:
Abbey Campbell authored and GitHub committed 2026-06-29 15:01:41 -07:00
commit 2a90146ec2
16 files changed
+251 -21

No files matched your search

@@ -46,10 +46,10 @@ const AddObsButton = ( ): React.Node => {
// Base trigger condition in all cases:
// Only show the tooltip if the user has the AI camera as the default button option.
// Only show the tooltip on MyObservations screen.
const onObsList = currentRoute?.name === "MyObservationsResults";
const isOnMyObservationsResults = currentRoute?.name === "MyObservationsResults";
const onlyAiCamera = !isAllAddObsOptionsMode;
let triggerCondition = onObsList && onlyAiCamera;
let triggerCondition = isOnMyObservationsResults && onlyAiCamera;
if ( justFinishedSignup ) {
// If a user creates a new account, they should see the tooltip right after dismissing the
@@ -1,9 +1,10 @@
import classnames from "classnames";
import {
BackButton,
Body3,
Heading4,
} from "components/SharedComponents";
import { View } from "components/styledComponents";
import { Pressable, View } from "components/styledComponents";
import React from "react";
import { useTranslation } from "sharedHooks";
@@ -11,6 +12,7 @@ interface Props {
closeModal: ( ) => void;
headerText: string;
resetFilters: ( ) => void;
resetDisabled?: boolean;
testID: string;
}
@@ -18,6 +20,7 @@ const ExploreSearchHeader = ( {
closeModal,
headerText,
resetFilters,
resetDisabled = false,
testID,
}: Props ) => {
const { t } = useTranslation( );
@@ -32,11 +35,21 @@ const ExploreSearchHeader = ( {
/>
</View>
<Heading4 className="flex-1 wrap text-center">{headerText}</Heading4>
<View className="w-[50px] items-end">
<Body3 onPress={resetFilters} maxFontSizeMultiplier={1.5}>
<Pressable
className={classnames(
"w-[50px] items-end",
{ "opacity-50": resetDisabled },
)}
onPress={resetFilters}
disabled={resetDisabled}
accessibilityRole="button"
accessibilityLabel={t( "Reset-verb" )}
testID={`${testID}.reset`}
>
<Body3 maxFontSizeMultiplier={1.5}>
{t( "Reset-verb" )}
</Body3>
</View>
</Pressable>
</View>
);
};
@@ -35,6 +35,8 @@ import {
} from "sharedHelpers/speciesSort";
import { accessibleTaxonName } from "sharedHelpers/taxon";
import { useGridLayout, useLayoutPrefs, useTranslation } from "sharedHooks";
import useFeatureFlag from "sharedHooks/useFeatureFlag";
import { FeatureFlag } from "stores/createFeatureFlagSlice";
import colors from "styles/tailwindColors";
import type { SpeciesCount } from "types/sorting";
@@ -42,6 +44,7 @@ import LoginSheet from "./LoginSheet";
import { ACTIVE_SHEET } from "./MyObservationsResults";
import MyObservationsSimpleHeader from "./MyObservationsSimpleHeader";
import PivotCardObsGridItem from "./PivotCardObsGridItem";
import SearchedTaxonBanner from "./Search/SearchedTaxonBanner";
import SimpleHeader from "./SimpleHeader";
import SimpleTaxonGridItem from "./SimpleTaxonGridItem";
@@ -131,6 +134,9 @@ const MyObservationsSimple = ( {
}: Props ) => {
const { isDefaultMode } = useLayoutPrefs( );
const { t } = useTranslation( );
const searchMyObservationsEnabled = useFeatureFlag(
FeatureFlag.SearchMyObservationsEnabled,
);
const speciesSortLabels = useSpeciesSortLabels( );
const navigation = useNavigation( );
const route = useRoute( );
@@ -358,6 +364,9 @@ const MyObservationsSimple = ( {
]}
TabComponent={renderTabComponent}
/>
{searchMyObservationsEnabled && activeTab === OBSERVATIONS_TAB && (
<SearchedTaxonBanner />
)}
{ activeTab === OBSERVATIONS_TAB && (
<>
<ObservationsFlashList
@@ -10,6 +10,7 @@ import {
SCREEN_NAME_SEARCH_MY_OBSERVATIONS,
} from "navigation/StackNavigators/MyObservationsStackNavigator";
import type { MyObservationsStackScreenProps } from "navigation/types";
import { useMyObservations } from "providers/MyObservationsContext";
import React from "react";
import { Alert } from "react-native";
import type {
@@ -51,6 +52,8 @@ const MyObservationsSimpleHeader = ( {
const searchMyObservationsEnabled = useFeatureFlag(
FeatureFlag.SearchMyObservationsEnabled,
);
const { state: myObsState } = useMyObservations( );
const searchActive = myObsState.searchedTaxon !== null;
const handleSearchButtonPress = ( ) => {
if ( !isConnected ) {
@@ -111,6 +114,9 @@ const MyObservationsSimpleHeader = ( {
<INatIconButton
icon="magnifying-glass"
onPress={handleSearchButtonPress}
color={searchActive
? colors.inatGreen
: colors.darkGray}
accessibilityLabel={t( "Opens-search-interface" )}
size={20}
/>
@@ -0,0 +1,38 @@
// TODO: This component is intentionally not rendered anywhere yet.
// This is the empty state for the Search My Observations feature.
import {
Body1,
Button,
} from "components/SharedComponents";
import { View } from "components/styledComponents";
import {
MY_OBSERVATIONS_ACTION,
useMyObservations,
} from "providers/MyObservationsContext";
import React from "react";
import { useTranslation } from "sharedHooks";
const SearchEmptyState = ( ) => {
const { t } = useTranslation( );
const { dispatch } = useMyObservations( );
return (
<View className="flex-1 px-5 items-center justify-center">
<Body1 className="text-center mb-6">
{t( "Looks-like-you-havent-observed-this-yet-time-to-keep-exploring" )}
</Body1>
<Button
level="focus"
className="w-full"
text={t( "RESET-SEARCH" )}
onPress={( ) => dispatch( {
type: MY_OBSERVATIONS_ACTION.CLEAR_TAXON_SEARCH,
} )}
testID="MyObservationsSearchEmptyState.reset"
/>
</View>
);
};
export default SearchEmptyState;
@@ -14,14 +14,23 @@ import {
useMyObservations,
} from "providers/MyObservationsContext";
import React, { useCallback, useState } from "react";
import { useTranslation } from "sharedHooks";
import type { RealmTaxon, RealmUser } from "realmModels/types";
import { taxonDisplayName } from "sharedHelpers/taxon";
import { useCurrentUser, useTranslation } from "sharedHooks";
import useTaxonSearch from "sharedHooks/useTaxonSearch";
const SearchMyObservationsTaxon = ( ) => {
const { t } = useTranslation( );
const navigation = useNavigation( );
const { dispatch } = useMyObservations( );
const [taxonQuery, setTaxonQuery] = useState( "" );
const { state, dispatch } = useMyObservations( );
const { searchedTaxon } = state;
const currentUser = useCurrentUser( ) as RealmUser | null;
const [taxonQuery, setTaxonQuery] = useState( ( ) => (
searchedTaxon
? taxonDisplayName( searchedTaxon, currentUser )
: ""
) );
const { taxa, isLoading, isLocal } = useTaxonSearch( taxonQuery );
@@ -29,12 +38,21 @@ const SearchMyObservationsTaxon = ( ) => {
const onTaxonSelected = useCallback( ( newTaxon: ApiTaxon | null ) => {
if ( newTaxon && typeof newTaxon.id === "number" && newTaxon.name ) {
// useTaxonSearch can return either ApiTaxon-shaped or RealmTaxon-shaped
// taxa depending on the source, so we have to check for both here.
// TODO: normalize taxa at ingest.
const realmTaxon = newTaxon as RealmTaxon;
const iconUri = newTaxon.default_photo?.url
|| realmTaxon.defaultPhoto?.url;
const preferredCommonName = newTaxon.preferred_common_name
|| realmTaxon.preferredCommonName;
dispatch( {
type: MY_OBSERVATIONS_ACTION.SET_TAXON_SEARCH,
searchTaxon: {
id: newTaxon.id,
name: newTaxon.name,
preferred_common_name: newTaxon.preferred_common_name,
preferredCommonName,
iconUri,
},
} );
} else {
@@ -43,7 +61,11 @@ const SearchMyObservationsTaxon = ( ) => {
closeScreen( );
}, [closeScreen, dispatch] );
const resetSearch = useCallback( ( ) => setTaxonQuery( "" ), [] );
const resetSearch = useCallback( ( ) => {
setTaxonQuery( "" );
dispatch( { type: MY_OBSERVATIONS_ACTION.CLEAR_TAXON_SEARCH } );
closeScreen( );
}, [closeScreen, dispatch] );
const renderItem = useCallback(
// no-unused-prop-types failing for components defined at runtime seems to
@@ -68,6 +90,7 @@ const SearchMyObservationsTaxon = ( ) => {
closeModal={closeScreen}
headerText={t( "SEARCH" )}
resetFilters={resetSearch}
resetDisabled={!searchedTaxon}
testID="SearchMyObservationsTaxon.close"
/>
<TaxonSearch
@@ -0,0 +1,60 @@
import {
Body3,
INatIconButton,
} from "components/SharedComponents";
import { Image, View } from "components/styledComponents";
import {
MY_OBSERVATIONS_ACTION,
useMyObservations,
} from "providers/MyObservationsContext";
import React from "react";
import type { RealmUser } from "realmModels/types";
import { taxonDisplayName } from "sharedHelpers/taxon";
import { useCurrentUser, useTranslation } from "sharedHooks";
const SearchedTaxonBanner = ( ) => {
const { t } = useTranslation( );
const { state, dispatch } = useMyObservations( );
const { searchedTaxon } = state;
// The Realm `User` class doesn't declare its schema fields as typed
// instance properties (see realmModels/User.ts), so cast to the typed
// RealmUser interface to access the user's name-display prefs.
const currentUser = useCurrentUser( ) as RealmUser | null;
if ( !searchedTaxon ) return null;
const displayName = taxonDisplayName( searchedTaxon, currentUser );
return (
<View
className="flex-row items-center bg-white space-x-[20px]"
testID="SearchedTaxonBanner"
>
<View className="flex-1 flex-row items-center space-x-[10px]">
<View className="w-[44px] h-[44px] bg-lightGray">
{searchedTaxon.iconUri && (
<Image
source={{ uri: searchedTaxon.iconUri }}
className="w-full h-full"
accessibilityIgnoresInvertColors
/>
)}
</View>
<Body3 className="flex-1" numberOfLines={1}>
{displayName}
</Body3>
</View>
<INatIconButton
icon="close"
size={14}
accessibilityLabel={t( "Close-search" )}
onPress={( ) => dispatch( {
type: MY_OBSERVATIONS_ACTION.CLEAR_TAXON_SEARCH,
} )}
/>
</View>
);
};
export default SearchedTaxonBanner;
@@ -4,6 +4,7 @@ import {
INatIcon,
} from "components/SharedComponents";
import { View } from "components/styledComponents";
import { useMyObservations } from "providers/MyObservationsContext";
import React from "react";
import { useCurrentUser, useTranslation } from "sharedHooks";
import useStore from "stores/useStore";
@@ -37,6 +38,13 @@ const SimpleHeader = ( {
const setIsDefaultMode = useStore( state => state.layout.setIsDefaultMode );
const currentUser = useCurrentUser();
const { state: myObsState } = useMyObservations( );
const searchActive = myObsState.searchedTaxon !== null;
// While a search is active, the SearchedTaxonBanner is the only banner we
// want to show. Suppress the announcement / advanced-mode / location-missing
// banners so they don't compete for attention or change the layout.
if ( searchActive ) return null;
const shouldShowAdvancedModeBanner = !!currentUser
&& isDefaultMode
+4
View File
@@ -732,6 +732,10 @@ LOG-IN-TO-INATURALIST = LOG IN TO INATURALIST
Log-in-to-iNaturalist = Log in to iNaturalist
LOG-OUT = LOG OUT
LOG-OUT--question = LOG OUT?
# When the user searches their own observations for something they haven't observed
Looks-like-you-havent-observed-this-yet-time-to-keep-exploring =
Looks like you haven't observed this yet.
Time to keep exploring!
Lowest = Lowest
LOWEST-RANK = LOWEST RANK
MAP = MAP
+1
View File
@@ -422,6 +422,7 @@
"Log-in-to-iNaturalist": "Log in to iNaturalist",
"LOG-OUT": "LOG OUT",
"LOG-OUT--question": "LOG OUT?",
"Looks-like-you-havent-observed-this-yet-time-to-keep-exploring": "Looks like you haven't observed this yet.\nTime to keep exploring!",
"Lowest": "Lowest",
"LOWEST-RANK": "LOWEST RANK",
"MAP": "MAP",
+4
View File
@@ -732,6 +732,10 @@ LOG-IN-TO-INATURALIST = LOG IN TO INATURALIST
Log-in-to-iNaturalist = Log in to iNaturalist
LOG-OUT = LOG OUT
LOG-OUT--question = LOG OUT?
# When the user searches their own observations for something they haven't observed
Looks-like-you-havent-observed-this-yet-time-to-keep-exploring =
Looks like you haven't observed this yet.
Time to keep exploring!
Lowest = Lowest
LOWEST-RANK = LOWEST RANK
MAP = MAP
+2 -1
View File
@@ -12,7 +12,8 @@ export enum MY_OBSERVATIONS_ACTION {
export interface MyObservationsTaxon {
id: number;
name: string;
preferred_common_name?: string;
preferredCommonName?: string;
iconUri?: string;
}
export interface MyObservationsState {
+19
View File
@@ -179,6 +179,25 @@ export function accessibleTaxonName(
return t( "accessible-comname-sciname", { scientificName, commonName } );
}
interface TaxonName {
name: string;
preferredCommonName?: string;
}
// Returns a single taxon name to show for a taxon in a single-line context
// like banners & search bars, honoring the user's name-display preferences.
// For two-line rendering with italics, use <DisplayTaxonName /> instead.
export function taxonDisplayName(
taxon: TaxonName,
user: User | null,
): string {
const preferScientific = user?.prefers_scientific_name_first === true
|| user?.prefers_common_names === false;
return preferScientific
? taxon.name
: ( taxon.preferredCommonName || taxon.name );
}
// Translates rank in a way that can be statically checked
export function translatedRank( rank: string, t: ( key: string ) => string ) {
switch ( rank ) {
+16 -1
View File
@@ -1,3 +1,4 @@
import { useNetInfo } from "@react-native-community/netinfo";
import { fetchSearchResults } from "api/search";
import type { ApiOpts } from "api/types";
import { RealmContext } from "providers/contexts";
@@ -32,6 +33,7 @@ function saveTaxaToRealm( taxa: Taxon[], realm: Realm ) {
const useTaxonSearch = ( taxonQueryArg = "" ) => {
const realm = useRealm( );
const iconicTaxa = useIconicTaxa( { reload: false } );
const { isConnected } = useNetInfo( );
// Remove leading and trailing whitespace, no need to perform new queries or
// potentially get different results b/c of meaningless whitespace
const taxonQuery = taxonQueryArg.trim();
@@ -39,7 +41,9 @@ const useTaxonSearch = ( taxonQueryArg = "" ) => {
const shouldFetchRemote = taxonQuery.length > 0;
const { data: remoteTaxa, refetch, isLoading } = useAuthenticatedQuery(
const {
data: remoteTaxa, refetch, isLoading, isFetched,
} = useAuthenticatedQuery(
["fetchTaxonSuggestions", taxonQuery],
async ( optsWithAuth: ApiOpts ) => {
const apiTaxa = await fetchSearchResults(
@@ -96,6 +100,14 @@ const useTaxonSearch = ( taxonQueryArg = "" ) => {
if ( isLoading ) return;
// Don't fall back to local results until the remote query has actually
// run at least once. useAuthenticatedQuery starts with enabled=false
// while it resolves the auth state, which makes isLoading=false even
// though no remote fetch has happened yet — without this check we'd
// flash the "Showing offline search results" callout in that window.
// Skip the gate when we know we're offline and will need remote taxa
if ( shouldFetchRemote && !isFetched && isConnected !== false ) return;
if ( remoteTaxa && remoteTaxa.length > 0 ) {
if ( isSubscribed ) setLocalTaxa( null );
return;
@@ -116,10 +128,13 @@ const useTaxonSearch = ( taxonQueryArg = "" ) => {
isSubscribed = false;
};
}, [
isConnected,
isFetched,
isLoading,
realm,
remoteTaxa,
safeRealmSearch,
shouldFetchRemote,
taxonQuery,
] );
@@ -1,6 +1,7 @@
import { screen } from "@testing-library/react-native";
import MyObservationsSimple, { OBSERVATIONS_TAB }
from "components/MyObservations/MyObservationsSimple";
import { MyObservationsProvider } from "providers/MyObservationsContext";
import React from "react";
// import DeviceInfo from "react-native-device-info";
import useDeviceOrientation from "sharedHooks/useDeviceOrientation";
@@ -59,14 +60,16 @@ jest.mock( "sharedHooks/useDeviceOrientation", ( ) => ( {
} ) );
const renderMyObservations = layout => renderComponent(
<MyObservationsSimple
layout={layout}
observations={mockObservations}
onEndReached={jest.fn( )}
toggleLayout={jest.fn( )}
setShowLoginSheet={jest.fn( )}
activeTab={OBSERVATIONS_TAB}
/>,
<MyObservationsProvider>
<MyObservationsSimple
layout={layout}
observations={mockObservations}
onEndReached={jest.fn( )}
toggleLayout={jest.fn( )}
setShowLoginSheet={jest.fn( )}
activeTab={OBSERVATIONS_TAB}
/>
</MyObservationsProvider>,
);
describe( "MyObservationsSimple", () => {
@@ -1,3 +1,4 @@
import { useNetInfo } from "@react-native-community/netinfo";
import { renderHook } from "@testing-library/react-native";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import useTaxonSearch from "sharedHooks/useTaxonSearch";
@@ -18,6 +19,7 @@ jest.mock( "sharedHooks/useAuthenticatedQuery", ( ) => ( {
data: [],
refetch: jest.mock( ),
isLoading: false,
isFetched: true,
} ) ),
} ) );
@@ -74,6 +76,7 @@ describe( "useTaxonSearch", ( ) => {
data: [factory( "LocalTaxon" )],
refetch: jest.mock( ),
isLoading: false,
isFetched: true,
} ) );
}
@@ -88,4 +91,27 @@ describe( "useTaxonSearch", ( ) => {
renderHook( ( ) => useTaxonSearch( "foo" ) );
expect( mockRealmObjects ).not.toHaveBeenCalledWith( "Taxon" );
} );
it( "when online should not request local taxa while remote query has not run yet", ( ) => {
useAuthenticatedQuery.mockImplementation( ( ) => ( {
data: undefined,
refetch: jest.mock( ),
isLoading: false,
isFetched: false,
} ) );
renderHook( ( ) => useTaxonSearch( "foo" ) );
expect( mockRealmObjects ).not.toHaveBeenCalledWith( "Taxon" );
} );
it( "when offline should request local taxa offline even before the remote query runs", ( ) => {
useNetInfo.mockReturnValueOnce( { isConnected: false } );
useAuthenticatedQuery.mockImplementation( ( ) => ( {
data: undefined,
refetch: jest.mock( ),
isLoading: false,
isFetched: false,
} ) );
renderHook( ( ) => useTaxonSearch( "foo" ) );
expect( mockRealmObjects ).toHaveBeenCalledWith( "Taxon" );
} );
} );