diff --git a/src/components/Explore/Explore.js b/src/components/Explore/Explore.js index fb294877e..8440c8021 100644 --- a/src/components/Explore/Explore.js +++ b/src/components/Explore/Explore.js @@ -48,7 +48,6 @@ type Props = { closeFiltersModal: Function, count: Object, currentExploreView: string, - currentMapRegion: Object, filterByIconicTaxonUnknown: Function, handleUpdateCount: Function, hasLocationPermissions?: boolean, @@ -61,7 +60,6 @@ type Props = { renderLocationPermissionsGate: Function, requestLocationPermissions: Function, setCurrentExploreView: Function, - setCurrentMapRegion: Function, showFiltersModal: boolean, startFetching: Function, updateLocation: Function, @@ -75,7 +73,6 @@ const Explore = ( { closeFiltersModal, count, currentExploreView, - currentMapRegion, filterByIconicTaxonUnknown, handleUpdateCount, hasLocationPermissions, @@ -88,7 +85,6 @@ const Explore = ( { renderLocationPermissionsGate, requestLocationPermissions, setCurrentExploreView, - setCurrentMapRegion, showFiltersModal, startFetching, updateLocation, @@ -162,8 +158,9 @@ const Explore = ( { layout={layout} queryParams={queryParams} handleUpdateCount={handleUpdateCount} - currentMapRegion={currentMapRegion} - setCurrentMapRegion={setCurrentMapRegion} + hasLocationPermissions={hasLocationPermissions} + renderLocationPermissionsGate={renderLocationPermissionsGate} + requestLocationPermissions={requestLocationPermissions} /> )} {currentExploreView === "species" && ( diff --git a/src/components/Explore/ExploreContainer.js b/src/components/Explore/ExploreContainer.js index e9bf88249..e9bbc1a72 100644 --- a/src/components/Explore/ExploreContainer.js +++ b/src/components/Explore/ExploreContainer.js @@ -26,8 +26,6 @@ const ExploreContainerWithContext = ( ): Node => { const { isConnected } = useNetInfo( ); const exploreView = useStore( state => state.exploreView ); const setExploreView = useStore( state => state.setExploreView ); - const mapRegion = useStore( s => s.mapRegion ); - const setMapRegion = useStore( s => s.setMapRegion ); const { hasPermissions: hasLocationPermissions, @@ -160,8 +158,6 @@ const ExploreContainerWithContext = ( ): Node => { renderLocationPermissionsGate={renderPermissionsGate} requestLocationPermissions={requestLocationPermissions} startFetching={startFetching} - currentMapRegion={mapRegion} - setCurrentMapRegion={setMapRegion} /> {renderPermissionsGate( { onPermissionGranted: startFetching diff --git a/src/components/Explore/MapView.tsx b/src/components/Explore/MapView.tsx index af82470c4..4fad93a3d 100644 --- a/src/components/Explore/MapView.tsx +++ b/src/components/Explore/MapView.tsx @@ -5,19 +5,48 @@ import { } from "components/SharedComponents"; import { getMapRegion } from "components/SharedComponents/Map/helpers/mapHelpers.ts"; import { View } from "components/styledComponents"; -import { MapBoundaries, PLACE_MODE, useExplore } from "providers/ExploreContext.tsx"; -import React, { useEffect } from "react"; +import { + EXPLORE_ACTION, MapBoundaries, PLACE_MODE, useExplore +} from "providers/ExploreContext.tsx"; +import React, { + useEffect, useMemo, useRef, useState +} from "react"; import { Region } from "react-native-maps"; import { useTranslation } from "sharedHooks"; import { getShadow } from "styles/global"; -import useMapLocation from "./hooks/useMapLocation"; +const NEARBY_DELTA = 0.02; + +const WORLDWIDE_DELTA = 180; +const WORLDWIDE_LAT_LNG = 0.0; + +const worldwideRegion = { + latitude: WORLDWIDE_LAT_LNG, + longitude: WORLDWIDE_LAT_LNG, + latitudeDelta: WORLDWIDE_DELTA, + longitudeDelta: WORLDWIDE_DELTA +}; const DROP_SHADOW = getShadow( { offsetHeight: 4, elevation: 6 } ); +const activityIndicatorSize = 50; +const centeredLoadingWheel = { + position: "absolute", + top: "50%", + left: "50%", + transform: [ + { translateX: -( activityIndicatorSize / 2 ) }, + { translateY: -( activityIndicatorSize / 2 ) } + ], + backgroundColor: "rgba(0,0,0,0)", + alignItems: "center", + justifyContent: "center", + zIndex: 20 +}; + interface Props { // Bounding box of the observations retrieved for the query params observationBounds?: MapBoundaries, @@ -27,42 +56,104 @@ interface Props { order?: string; orderBy?: string; }; - currentMapRegion: Region; - setCurrentMapRegion: ( Region ) => void; - isLoading: boolean + isLoading: boolean, + hasLocationPermissions?: boolean, + renderLocationPermissionsGate: Function, + requestLocationPermissions: Function } const MapView = ( { observationBounds, queryParams, - currentMapRegion, isLoading, - setCurrentMapRegion + hasLocationPermissions, + renderLocationPermissionsGate, + requestLocationPermissions }: Props ) => { const { t } = useTranslation( ); - const { state: exploreState } = useExplore( ); + const { state: exploreState, dispatch, defaultExploreLocation } = useExplore( ); + const [showRedoSearchButton, setShowRedoSearchButton] = useState( false ); + const isFirstRender = useRef( true ); - const { - onPanDrag, - redoSearchInMapArea, - region, - showMapBoundaryButton, - updateMapBoundaries - } = useMapLocation( currentMapRegion, setCurrentMapRegion ); + const mapRef = useRef( null ); - // TODO this should really be a part of the explore reducer - useEffect( ( ) => { - if ( - observationBounds - && [ - PLACE_MODE.WORLDWIDE, - PLACE_MODE.PLACE, - PLACE_MODE.NEARBY - ].indexOf( exploreState.placeMode ) >= 0 - ) { - updateMapBoundaries( getMapRegion( observationBounds ) ); + const nearbyRegion = useMemo( () => ( { + latitude: exploreState.lat, + longitude: exploreState.lng, + latitudeDelta: NEARBY_DELTA, + longitudeDelta: NEARBY_DELTA + } ), [exploreState.lat, exploreState.lng] ); + + const regionFromCoordinates = useMemo( ( ) => { + if ( exploreState.place?.point_geojson?.coordinates ) { + const [longitude, latitude] = exploreState.place.point_geojson.coordinates; + return { + latitude, + longitude, + latitudeDelta: NEARBY_DELTA, + longitudeDelta: NEARBY_DELTA + }; } - }, [observationBounds, updateMapBoundaries, exploreState.placeMode] ); + return null; + }, [exploreState.place] ); + + useEffect( ( ) => { + // Skip animation on first render + if ( isFirstRender.current ) { + isFirstRender.current = false; + return; + } + + if ( mapRef.current + && exploreState.placeMode === PLACE_MODE.MAP_AREA ) { + return; + } + + // since we're using initialRegion, we need to animate to the correct zoom level + // when a user switches back to NEARBY or WORLDWIDE + if ( mapRef.current + && exploreState.placeMode === PLACE_MODE.NEARBY ) { + // Note: we do get observationBounds back from the API for nearby + // but per user feedback, we want to show users a more zoomed in view + // when they're looking at NEARBY view + if ( nearbyRegion.latitude !== undefined && nearbyRegion.longitude !== undefined ) { + mapRef.current.animateToRegion( nearbyRegion ); + } + return; + } + if ( mapRef.current + && exploreState.placeMode === PLACE_MODE.WORLDWIDE ) { + mapRef.current.animateToRegion( worldwideRegion ); + } + if ( mapRef.current + && exploreState.placeMode === PLACE_MODE.PLACE ) { + if ( observationBounds ) { + const newRegion = getMapRegion( observationBounds ); + mapRef.current.animateToRegion( newRegion ); + } + } + }, [ + exploreState.placeMode, + nearbyRegion, + regionFromCoordinates, + observationBounds, + exploreState.place?.id + ] ); + + const handleRedoSearch = async ( ) => { + setShowRedoSearchButton( false ); + const currentBounds = await mapRef?.current?.getMapBoundaries( ); + dispatch( { type: EXPLORE_ACTION.SET_PLACE_MODE_MAP_AREA } ); + dispatch( { + type: EXPLORE_ACTION.SET_MAP_BOUNDARIES, + mapBoundaries: { + swlat: currentBounds.southWest.latitude, + swlng: currentBounds.southWest.longitude, + nelat: currentBounds.northEast.latitude, + nelng: currentBounds.northEast.longitude + } + } ); + }; const tileMapParams = { ...queryParams @@ -72,10 +163,40 @@ const MapView = ( { delete tileMapParams.order; delete tileMapParams.orderBy; + const initialRegion: Region = useMemo( () => { + if ( exploreState.placeMode === PLACE_MODE.NEARBY ) { + if ( nearbyRegion.latitude !== undefined && nearbyRegion.longitude !== undefined ) { + return nearbyRegion; + } + } + + if ( exploreState.placeMode === PLACE_MODE.PLACE ) { + if ( regionFromCoordinates ) { + return regionFromCoordinates; + } + } + + return worldwideRegion; + }, [exploreState.placeMode, nearbyRegion, regionFromCoordinates] ); + + const handlePanDrag = ( ) => setShowRedoSearchButton( true ); + + const handleCurrentLocationPress = async ( ) => { + if ( hasLocationPermissions ) { + const exploreLocation = await defaultExploreLocation( ); + dispatch( { + type: EXPLORE_ACTION.SET_EXPLORE_LOCATION, + exploreLocation + } ); + } else { + requestLocationPermissions( ); + } + }; + return ( - {showMapBoundaryButton && ( + {showRedoSearchButton && ( )} - {isLoading - ? - : ( - - )} + + {isLoading && ( + + + + )} + {renderLocationPermissionsGate( { onPermissionGranted: handleCurrentLocationPress } )} ); }; diff --git a/src/components/Explore/ObservationsView.js b/src/components/Explore/ObservationsView.js index 1a7e2061f..e1a7f5141 100644 --- a/src/components/Explore/ObservationsView.js +++ b/src/components/Explore/ObservationsView.js @@ -28,8 +28,9 @@ type Props = { layout: string, queryParams: Object, handleUpdateCount: Function, - currentMapRegion: Object, - setCurrentMapRegion: Function + hasLocationPermissions?: boolean, + renderLocationPermissionsGate: Function, + requestLocationPermissions: Function } const OBS_LIST_CONTAINER_STYLE = { paddingTop: 50 }; @@ -41,8 +42,9 @@ const ObservationsView = ( { layout, queryParams, handleUpdateCount, - currentMapRegion, - setCurrentMapRegion + hasLocationPermissions, + renderLocationPermissionsGate, + requestLocationPermissions }: Props ): Node => { const currentUser = useCurrentUser( ); const { state } = useExplore(); @@ -139,8 +141,9 @@ const ObservationsView = ( { observationBounds={totalBounds} isLoading={isLoading} queryParams={queryParams} - currentMapRegion={currentMapRegion} - setCurrentMapRegion={setCurrentMapRegion} + hasLocationPermissions={hasLocationPermissions} + renderLocationPermissionsGate={renderLocationPermissionsGate} + requestLocationPermissions={requestLocationPermissions} /> ); diff --git a/src/components/Explore/ObservationsViewBar.js b/src/components/Explore/ObservationsViewBar.js index b963492cd..02ea1125a 100644 --- a/src/components/Explore/ObservationsViewBar.js +++ b/src/components/Explore/ObservationsViewBar.js @@ -8,7 +8,6 @@ import { getShadow } from "styles/global"; import colors from "styles/tailwindColors"; type Props = { - gridFirst?: boolean, hideMap?: boolean, layout: string, updateObservationsView: Function @@ -20,30 +19,26 @@ const DROP_SHADOW = getShadow( { } ); const ObservationsViewBar = ( { - gridFirst, hideMap, layout, updateObservationsView }: Props ): Node => { const buttons = [ - { - value: "list", - icon: "hamburger-menu", - accessibilityLabel: "List", - testID: "SegmentedButton.list" - }, { value: "grid", icon: "grid", accessibilityLabel: "Grid", testID: "SegmentedButton.grid" + }, + { + value: "list", + icon: "hamburger-menu", + accessibilityLabel: "List", + testID: "SegmentedButton.list" } ]; - if ( gridFirst ) { - buttons.reverse( ); - } if ( !hideMap ) { - buttons.push( { + buttons.unshift( { value: "map", icon: "map", accessibilityLabel: "Map", diff --git a/src/components/Explore/RootExploreContainer.js b/src/components/Explore/RootExploreContainer.js index e99989c2e..490ef0e9e 100644 --- a/src/components/Explore/RootExploreContainer.js +++ b/src/components/Explore/RootExploreContainer.js @@ -33,8 +33,6 @@ const RootExploreContainerWithContext = ( ): Node => { const setRootExploreView = useStore( state => state.setRootExploreView ); const rootStoredParams = useStore( state => state.rootStoredParams ); const setRootStoredParams = useStore( state => state.setRootStoredParams ); - const rootMapRegion = useStore( s => s.rootMapRegion ); - const setRootMapRegion = useStore( s => s.setRootMapRegion ); const { hasPermissions: hasLocationPermissions, @@ -231,8 +229,6 @@ const RootExploreContainerWithContext = ( ): Node => { hasLocationPermissions={hasLocationPermissions} requestLocationPermissions={requestLocationPermissions} startFetching={startFetching} - currentMapRegion={rootMapRegion} - setCurrentMapRegion={setRootMapRegion} renderLocationPermissionsGate={renderPermissionsGate} /> {renderPermissionsGate( { diff --git a/src/components/Explore/SearchScreens/ExploreTaxonSearch.js b/src/components/Explore/SearchScreens/ExploreTaxonSearch.js index efab2d9be..79ae4530c 100644 --- a/src/components/Explore/SearchScreens/ExploreTaxonSearch.js +++ b/src/components/Explore/SearchScreens/ExploreTaxonSearch.js @@ -52,6 +52,7 @@ const ExploreTaxonSearch = ( { const renderItem = useCallback( ( { item: taxon, index } ) => ( onTaxonSelected( taxon )} hideInfoButton={hideInfoButton} onPressInfo={onPressInfo} diff --git a/src/components/Explore/hooks/useMapLocation.ts b/src/components/Explore/hooks/useMapLocation.ts deleted file mode 100644 index 2c4c8b90a..000000000 --- a/src/components/Explore/hooks/useMapLocation.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - EXPLORE_ACTION, - MapBoundaries, - PLACE_MODE, - useExplore -} from "providers/ExploreContext.tsx"; -import { - useCallback, useEffect, useRef, useState -} from "react"; -import { BoundingBox, Region } from "react-native-maps"; -import { initialMapRegion } from "stores/createExploreSlice.ts"; - -const useMapLocation = ( currentMapRegion, setCurrentMapRegion ) => { - const { dispatch, state } = useExplore( ); - const [mapBoundaries, setMapBoundaries] = useState( ); - const [showMapBoundaryButton, setShowMapBoundaryButton] = useState( false ); - - const place = state?.place; - - const onPanDrag = ( ) => setShowMapBoundaryButton( true ); - - const mapWasReset = state.placeMode === PLACE_MODE.NEARBY - || state.placeMode === PLACE_MODE.WORLDWIDE; - const placeIdWasSet = state.place_id; - - // eslint-disable-next-line max-len - const updateMapBoundaries = useCallback( async ( newRegion: Region, boundaries?: BoundingBox ) => { - setCurrentMapRegion( newRegion ); - if ( boundaries ) { - const newMapBoundaries = { - swlat: boundaries.southWest.latitude, - swlng: boundaries.southWest.longitude, - nelat: boundaries.northEast.latitude, - nelng: boundaries.northEast.longitude - }; - setMapBoundaries( newMapBoundaries ); - return newMapBoundaries; - } - return {}; - }, [ - setMapBoundaries, - setCurrentMapRegion - ] ); - - const redoSearchInMapArea = ( ) => { - if ( !mapBoundaries ) return; - setShowMapBoundaryButton( false ); - dispatch( { type: EXPLORE_ACTION.SET_PLACE_MODE_MAP_AREA } ); - dispatch( { type: EXPLORE_ACTION.SET_MAP_BOUNDARIES, mapBoundaries } ); - }; - - const previousPlaceGuess = useRef( state.placeMode ); - useEffect( ( ) => { - // region gets set when a user is navigating from ExploreLocationSearch - if ( placeIdWasSet ) { - const coordinates = place?.point_geojson?.coordinates - ? place.point_geojson.coordinates - : place?.bounding_box_geojson?.coordinates; - if ( coordinates ) { - setCurrentMapRegion( { - ...initialMapRegion, - latitude: coordinates[1], - longitude: coordinates[0] - } ); - } - } else if ( mapWasReset ) { - // map gets set or reset back to nearby/worldwide, but only if the placeMode - // has changed - if ( previousPlaceGuess.current === state.placeMode ) { - return; - } - setCurrentMapRegion( { - ...initialMapRegion, - latitude: state?.lat, - longitude: state?.lng - } ); - previousPlaceGuess.current = state.placeMode; - } - }, [ - mapWasReset, - place, - placeIdWasSet, - setCurrentMapRegion, - state - ] ); - - return { - onPanDrag, - redoSearchInMapArea, - region: currentMapRegion, - showMapBoundaryButton, - updateMapBoundaries - }; -}; - -export default useMapLocation; diff --git a/src/components/MyObservations/MyObservationsSimple.tsx b/src/components/MyObservations/MyObservationsSimple.tsx index 67529d393..d751962fd 100644 --- a/src/components/MyObservations/MyObservationsSimple.tsx +++ b/src/components/MyObservations/MyObservationsSimple.tsx @@ -309,7 +309,6 @@ const MyObservationsSimple = ( { : )} /> { } ); }, [ONBOARDING_SLIDES, totalImages] ); - if ( !imagesLoaded ) { + // TODO: On Android release build imagesLoaded never switched from false to true, and + // this screen was stuck in a loading state. On iOS it worked as expected. + // Disabling it now on Android to make a new release possible. + if ( Platform.OS === "android" + ? false + : !imagesLoaded ) { return ( { - onPressRef.current = onPress; - }, [onPress] ); + onPressRef.current = onPress; const isPrimary = level === "primary"; const isWarning = level === "warning"; @@ -210,19 +208,16 @@ const Button = ( { const handlePress = ( event?: GestureResponderEvent ) => { if ( !preventMultipleTaps ) { - onPressRef.current( event ); - return; + return onPressRef.current( event ); } - if ( isProcessing ) return; - setIsProcessing( true ); - onPressRef.current( event ); setTimeout( ( ) => { setIsProcessing( false ); }, debounceTime ); + return null; }; const isDisabled = disabled || ( preventMultipleTaps && isProcessing ); diff --git a/src/components/SharedComponents/Map/CurrentLocationButton.tsx b/src/components/SharedComponents/Map/CurrentLocationButton.tsx index 0dad733ef..d8e7e104e 100644 --- a/src/components/SharedComponents/Map/CurrentLocationButton.tsx +++ b/src/components/SharedComponents/Map/CurrentLocationButton.tsx @@ -31,6 +31,7 @@ const CurrentLocationButton = ( { style={DROP_SHADOW} accessibilityLabel={t( "Zoom-to-current-location" )} onPress={onPress} + testID="Map.CurrentLocationButton" /> {renderPermissionsGate( )} diff --git a/src/components/SharedComponents/Map/Map.tsx b/src/components/SharedComponents/Map/Map.tsx index 702b78423..fc8e4f857 100644 --- a/src/components/SharedComponents/Map/Map.tsx +++ b/src/components/SharedComponents/Map/Map.tsx @@ -3,6 +3,7 @@ import classnames from "classnames"; import { Body1 } from "components/SharedComponents"; import { View } from "components/styledComponents"; import React, { + forwardRef, useCallback, useEffect, useMemo, @@ -53,7 +54,7 @@ interface Props { children?: React.ReactNode; className?: string; currentLocationButtonClassName?: string; - initialRegion?: boolean; + initialRegion?: Region; mapHeight?: DimensionValue; // allows for height to be defined as px or percentage mapType?: MapType; mapViewClassName?: string; @@ -64,7 +65,7 @@ interface Props { onRegionChangeComplete?: ( _r: Region, _b: BoundingBox | undefined ) => void; openMapScreen?: () => void; region?: Region; - regionToAnimate?: Object; + regionToAnimate?: Region; scrollEnabled?: boolean; showCurrentLocationButton?: boolean; showsCompass?: boolean; @@ -82,7 +83,7 @@ interface Props { // TODO: fallback to another map library // for people who don't use GMaps (i.e. users in China) -const Map = ( { +const Map = forwardRef( ( { children, className = "flex-1", currentLocationButtonClassName, @@ -111,7 +112,7 @@ const Map = ( { withPressableObsTiles, zoomEnabled = true, zoomTapEnabled = true -}: Props ) => { +}: Props, ref ) => { const { isDebug } = useDebugMode( ); const { screenWidth, screenHeight } = useDeviceOrientation( ); const [currentZoom, setCurrentZoom] = useState( 0 ); @@ -243,7 +244,9 @@ const Map = ( { // If we're supposed to be showing user location but we don't have it, ask // for permission again, which should result in fetching the location if // we can - if ( !userLocation ) { + // skipping onCurrentLocationPress here because the handlers + // are handling the permissions request outside of this component (example: Explore MapView) + if ( !userLocation && onCurrentLocationPress === undefined ) { requestPermissions( ); return; } @@ -413,9 +416,9 @@ const Map = ( { : unfuzzedMapRegion; // In Android, we maintain initialRegion as state localRegion and - // pass null to parameter initialRegion. + // pass undefined to parameter initialRegion. const mapInitialRegion = Platform.OS === "android" - ? null + ? undefined : initialRegion; const renderDebugZoomLevel = ( ) => { @@ -448,6 +451,18 @@ const Map = ( { const longitude = observation?.privateLongitude || observation?.longitude; const hasCoordinates = latitude && longitude; + const setRefs = instance => { + // Update our internal ref + mapViewRef.current = instance; + + // Forward to the parent ref + if ( typeof ref === "function" ) { + ref( instance ); + } else if ( ref ) { + ref.current = instance; + } + }; + return ( ); -}; +} ); export default Map; diff --git a/src/components/SharedComponents/SearchBar.tsx b/src/components/SharedComponents/SearchBar.tsx index 5b4e949c8..c942969a3 100644 --- a/src/components/SharedComponents/SearchBar.tsx +++ b/src/components/SharedComponents/SearchBar.tsx @@ -2,7 +2,7 @@ import { fontRegular } from "appConstants/fontFamilies.ts"; import classNames from "classnames"; import { INatIcon, INatIconButton } from "components/SharedComponents"; import { View } from "components/styledComponents"; -import React from "react"; +import React, { useCallback, useRef, useState } from "react"; import { Keyboard, TextInput as RNTextInput } from "react-native"; import { TextInput, useTheme } from "react-native-paper"; import { useTranslation } from "sharedHooks"; @@ -21,6 +21,7 @@ interface Props { placeholder?: string; testID?: string; value: string; + debounceTime?: number; } // Ensure this component is placed outside of scroll views @@ -34,10 +35,26 @@ const SearchBar = ( { input, placeholder, testID, - value + value, + debounceTime = 300 }: Props ) => { const theme = useTheme( ); const { t } = useTranslation( ); + const [localValue, setLocalValue] = useState( value ); + + const debounceTimeout = useRef>(); + + const debouncedHandleTextChange = useCallback( ( text: string ) => { + setLocalValue( text ); + + if ( debounceTimeout.current ) { + clearTimeout( debounceTimeout.current ); + } + + debounceTimeout.current = setTimeout( ( ) => { + handleTextChange( text ); + }, debounceTime ); + }, [handleTextChange, debounceTime] ); const outlineStyle = { borderColor: "lightgray", @@ -76,7 +93,7 @@ const SearchBar = ( { dense keyboardType="default" mode="outlined" - onChangeText={handleTextChange} + onChangeText={debouncedHandleTextChange} outlineStyle={outlineStyle} placeholder={placeholder} selectionColor={colors.darkGray} @@ -84,9 +101,9 @@ const SearchBar = ( { testID={testID} theme={fontTheme} underlineColor={colors.darkGray} - value={value} + value={localValue} /> - {value?.length > 0 && clearSearch + {localValue?.length > 0 && clearSearch ? ( { Keyboard.dismiss(); clearSearch(); + setLocalValue( "" ); }} /> diff --git a/src/sharedHooks/useTaxonSearch.ts b/src/sharedHooks/useTaxonSearch.ts index 11c4f5b13..7d5bbdd5a 100644 --- a/src/sharedHooks/useTaxonSearch.ts +++ b/src/sharedHooks/useTaxonSearch.ts @@ -1,7 +1,9 @@ import { fetchSearchResults } from "api/search.ts"; import type { ApiOpts } from "api/types"; import { RealmContext } from "providers/contexts.ts"; -import { useCallback, useEffect, useState } from "react"; +import { + useCallback, useEffect, useMemo, useState +} from "react"; import Realm, { UpdateMode } from "realm"; import Taxon from "realmModels/Taxon"; import type { RealmTaxon } from "realmModels/types"; @@ -34,6 +36,8 @@ const useTaxonSearch = ( taxonQueryArg = "" ) => { const taxonQuery = taxonQueryArg.trim(); const [localTaxa, setLocalTaxa] = useState( null ); + const shouldFetchRemote = taxonQuery.length > 0; + const { data: remoteTaxa, refetch, isLoading } = useAuthenticatedQuery( ["fetchTaxonSuggestions", taxonQuery], async ( optsWithAuth: ApiOpts ) => { @@ -50,16 +54,10 @@ const useTaxonSearch = ( taxonQueryArg = "" ) => { return apiTaxa?.map( taxon => Taxon.mapApiToRealm( taxon ) ) || []; }, { - enabled: !!( taxonQuery.length > 0 ) + enabled: shouldFetchRemote } ); - useEffect( ( ) => { - if ( realm && remoteTaxa?.length > 0 ) { - saveTaxaToRealm( remoteTaxa, realm ); - } - }, [realm, remoteTaxa] ); - const safeRealmSearch = useCallback( async ( searchString: string ) => { try { const { cleanedQuery } = validateRealmSearch( searchString ); @@ -82,53 +80,89 @@ const useTaxonSearch = ( taxonQueryArg = "" ) => { }, [realm] ); useEffect( ( ) => { - const searchLocalTaxa = async ( ) => { - if ( - taxonQuery.length > 0 - && !isLoading - && ( !remoteTaxa || remoteTaxa.length === 0 ) - ) { - try { - const results = await safeRealmSearch( taxonQuery ); - setLocalTaxa( results ); - } catch ( error ) { - console.error( "Local search failed:", error ); - setLocalTaxa( [] ); - } - } else { - setLocalTaxa( null ); + let isSubscribed = true; + const saveOrSearchRealmTaxa = async ( ) => { + // save taxa to realm if we have results from the API + if ( realm && remoteTaxa?.length > 0 ) { + saveTaxaToRealm( remoteTaxa, realm ); + } + // Search for local taxa if we have a query, if remote results are not loading + // and if remote results are empty + if ( taxonQuery.length === 0 ) { + if ( isSubscribed ) setLocalTaxa( null ); + return; + } + + if ( isLoading ) return; + + if ( remoteTaxa && remoteTaxa.length > 0 ) { + if ( isSubscribed ) setLocalTaxa( null ); + return; + } + + try { + const results = await safeRealmSearch( taxonQuery ); + if ( isSubscribed ) setLocalTaxa( results ); + } catch ( error ) { + console.error( "Local search failed:", error ); + if ( isSubscribed ) setLocalTaxa( [] ); } }; - searchLocalTaxa( ); - }, [taxonQuery, isLoading, remoteTaxa, safeRealmSearch] ); + saveOrSearchRealmTaxa( ); - // Show iconic taxa by default - if ( taxonQuery.length === 0 ) { + return ( ) => { + isSubscribed = false; + }; + }, [ + isLoading, + realm, + remoteTaxa, + safeRealmSearch, + taxonQuery + ] ); + + return useMemo( () => { + // Show iconic taxa by default (empty query) + if ( taxonQuery.length === 0 ) { + return { + taxa: iconicTaxa, + refetch: () => undefined, + isLoading: false, + isLocal: false + }; + } + + // Show remote taxa if available + if ( remoteTaxa && remoteTaxa.length > 0 ) { + return { + taxa: remoteTaxa, + refetch, + isLoading, + isLocal: false + }; + } + + // Show local taxa if available + if ( localTaxa !== null && localTaxa.length > 0 ) { + return { + taxa: localTaxa, + refetch: () => undefined, + isLoading: false, + isLocal: true + }; + } + + // Still loading or no results return { - taxa: iconicTaxa, - refetch: ( ) => undefined, - isLoading: false, + taxa: isLoading + ? [] + : localTaxa || [], + refetch, + isLoading, isLocal: false }; - } - - // Show local taxa if available - if ( localTaxa !== null && localTaxa.length > 0 ) { - return { - taxa: localTaxa, - refetch: ( ) => undefined, - isLoading: false, - isLocal: true - }; - } - - return { - taxa: remoteTaxa, - refetch, - isLoading, - isLocal: false - }; + }, [taxonQuery, remoteTaxa, localTaxa, iconicTaxa, refetch, isLoading] ); }; export default useTaxonSearch; diff --git a/src/stores/createExploreSlice.ts b/src/stores/createExploreSlice.ts index 7d247683a..10b3a7b19 100644 --- a/src/stores/createExploreSlice.ts +++ b/src/stores/createExploreSlice.ts @@ -1,37 +1,17 @@ import { StateCreator } from "zustand"; -const DELTA = 0.2; - -export const initialMapRegion = { - latitude: 0.0, - longitude: 0.0, - latitudeDelta: DELTA, - longitudeDelta: DELTA -}; - const DEFAULT_STATE = { - exploreView: "species", - mapRegion: initialMapRegion + exploreView: "observations" }; -interface MapRegion { - latitude: number, - longitude: number, - latitudeDelta: number, - longitudeDelta: number -} - interface ExploreSlice { exploreView: string, - setExploreView: ( _view: string ) => void, - mapRegion: MapRegion, - setMapRegion: ( _region: MapRegion ) => void + setExploreView: ( _view: string ) => void } const createExploreSlice: StateCreator = set => ( { ...DEFAULT_STATE, - setExploreView: exploreView => set( ( ) => ( { exploreView } ) ), - setMapRegion: mapRegion => set( ( ) => ( { mapRegion } ) ) + setExploreView: exploreView => set( ( ) => ( { exploreView } ) ) } ); export default createExploreSlice; diff --git a/src/stores/createRootExploreSlice.ts b/src/stores/createRootExploreSlice.ts index fe193067a..c78a82a21 100644 --- a/src/stores/createRootExploreSlice.ts +++ b/src/stores/createRootExploreSlice.ts @@ -1,41 +1,21 @@ import { StateCreator } from "zustand"; -const DELTA = 0.2; - -export const initialMapRegion = { - latitude: 0.0, - longitude: 0.0, - latitudeDelta: DELTA, - longitudeDelta: DELTA -}; - const DEFAULT_STATE = { rootStoredParams: {}, - rootExploreView: "species", - rootMapRegion: initialMapRegion + rootExploreView: "observations" }; -interface MapRegion { - latitude: number, - longitude: number, - latitudeDelta: number, - longitudeDelta: number -} - interface RootExploreSlice { rootStoredParams: Object, setRootStoredParams: ( _params: Object ) => void, rootExploreView: string, - setRootExploreView: ( _view: string ) => void, - rootMapRegion: MapRegion, - setRootMapRegion: ( _region: MapRegion ) => void + setRootExploreView: ( _view: string ) => void } const createRootExploreSlice: StateCreator = set => ( { ...DEFAULT_STATE, setRootStoredParams: rootStoredParams => set( ( ) => ( { rootStoredParams } ) ), - setRootExploreView: rootExploreView => set( ( ) => ( { rootExploreView } ) ), - setRootMapRegion: rootMapRegion => set( ( ) => ( { rootMapRegion } ) ) + setRootExploreView: rootExploreView => set( ( ) => ( { rootExploreView } ) ) } ); export default createRootExploreSlice; diff --git a/tests/integration/Explore.test.js b/tests/integration/Explore.test.js index ce8fe54e3..7cdb952cf 100644 --- a/tests/integration/Explore.test.js +++ b/tests/integration/Explore.test.js @@ -2,8 +2,7 @@ import { fireEvent, screen, userEvent, - waitFor, - within + waitFor } from "@testing-library/react-native"; import ExploreContainer from "components/Explore/ExploreContainer"; import inatjs from "inaturalistjs"; @@ -36,24 +35,25 @@ beforeAll( ( ) => { jest.useFakeTimers( ); } ); -const switchToObservationsView = async ( ) => { +const switchToSpeciesView = async ( ) => { + const observationsViewIcon = await screen.findByLabelText( /Observations View/ ); + expect( observationsViewIcon ).toBeVisible( ); + await actor.press( observationsViewIcon ); + const speciesRadioButton = await screen.findByText( "Species" ); + await actor.press( speciesRadioButton ); + const confirmButton = await screen.findByText( /EXPLORE SPECIES/ ); + await actor.press( confirmButton ); const speciesViewIcon = await screen.findByLabelText( /Species View/ ); expect( speciesViewIcon ).toBeVisible( ); - await actor.press( speciesViewIcon ); - const observationsRadioButton = await screen.findByText( "Observations" ); - await actor.press( observationsRadioButton ); - const bottomSheet = await screen.findByTestId( "ExploreObsViewSheet" ); - const confirmButton = await within( bottomSheet ).findByText( /EXPLORE OBSERVATIONS/ ); - expect( confirmButton ).toBeVisible( ); - await actor.press( confirmButton ); - const obsTaxonNameElt = await screen.findByText( mockRemoteObservation.taxon.name ); - expect( obsTaxonNameElt ).toBeTruthy( ); }; describe( "Explore", ( ) => { - it( "should render species view and switch to observations view list correctly", async ( ) => { + it( "should render observations view list correctly on page load", async ( ) => { renderAppWithComponent( ); - await switchToObservationsView( ); + const observationsViewIcon = await screen.findByLabelText( /Observations View/ ); + expect( observationsViewIcon ).toBeVisible( ); + const obsTaxonNameElt = await screen.findByText( mockRemoteObservation.taxon.name ); + expect( obsTaxonNameElt ).toBeTruthy( ); expect( await screen.findByTestId( `ObsStatus.${mockRemoteObservation.uuid}` ) ).toBeTruthy( ); @@ -62,9 +62,13 @@ describe( "Explore", ( ) => { ).toBeFalsy( ); } ); + it( "should switch to species view list correctly", async ( ) => { + renderAppWithComponent( ); + await switchToSpeciesView( ); + } ); + it( "should display observations view grid correctly", async ( ) => { renderAppWithComponent( ); - await switchToObservationsView( ); expect( await screen.findByTestId( "SegmentedButton.grid" ) ).toBeTruthy( ); @@ -77,9 +81,12 @@ describe( "Explore", ( ) => { ).toBeFalsy( ); } ); - it( "should trigger new observation fetch on pull-to-refresh", async ( ) => { + it( "should trigger new observation fetch on pull-to-refresh in list view", async ( ) => { renderAppWithComponent( ); - await switchToObservationsView( ); + expect( + await screen.findByTestId( "SegmentedButton.list" ) + ).toBeTruthy( ); + fireEvent.press( await screen.findByTestId( "SegmentedButton.list" ) ); const exploreObsList = await screen.findByTestId( "ExploreObservationsAnimatedList" ); @@ -97,7 +104,6 @@ describe( "Explore", ( ) => { it( "should trigger new observation fetch when filters change", async ( ) => { renderAppWithComponent( ); - await switchToObservationsView( ); // Clear the mock so we can make sure it gets called again inatjs.observations.search.mockClear( ); diff --git a/tests/integration/navigation/Explore.test.js b/tests/integration/navigation/Explore.test.js index d4fe6fa3a..3ec6275c1 100644 --- a/tests/integration/navigation/Explore.test.js +++ b/tests/integration/navigation/Explore.test.js @@ -116,6 +116,23 @@ async function navigateToRootExplore( ) { await actor.press( exploreButton ); } +const landOnObservationsView = async ( ) => { + const observationsViewIcon = await screen.findByLabelText( /Observations View/ ); + expect( observationsViewIcon ).toBeVisible( ); +}; + +const switchToSpeciesView = async ( ) => { + const observationsViewIcon = await screen.findByLabelText( /Observations View/ ); + expect( observationsViewIcon ).toBeVisible( ); + await actor.press( observationsViewIcon ); + const speciesRadioButton = await screen.findByText( "Species" ); + await actor.press( speciesRadioButton ); + const confirmButton = await screen.findByText( /EXPLORE SPECIES/ ); + await actor.press( confirmButton ); + const speciesViewIcon = await screen.findByLabelText( /Species View/ ); + expect( speciesViewIcon ).toBeVisible( ); +}; + describe( "logged in", ( ) => { beforeEach( async ( ) => { await signIn( mockUser, { realm: global.mockRealms[__filename] } ); @@ -240,8 +257,8 @@ describe( "logged in", ( ) => { it( "should navigate from TaxonDetails to Explore and back to TaxonDetails", async ( ) => { renderApp( ); await navigateToRootExplore( ); - const speciesViewIcon = await screen.findByLabelText( /Species View/ ); - expect( speciesViewIcon ).toBeVisible( ); + await landOnObservationsView( ); + await switchToSpeciesView( ); const firstTaxon = await screen.findByTestId( `TaxonGridItem.Pressable.${mockTaxon.id}` ); await actor.press( firstTaxon ); const taxonDetailsExploreButton = await screen.findByLabelText( /See observations of this taxon in explore/ ); @@ -263,12 +280,7 @@ describe( "logged in", ( ) => { inatjs.observations.fetch.mockResolvedValue( makeResponse( mockObservations ) ); renderApp( ); await navigateToRootExplore( ); - const speciesViewIcon = await screen.findByLabelText( /Species View/ ); - await actor.press( speciesViewIcon ); - const observationsRadioButton = await screen.findByText( "Observations" ); - await actor.press( observationsRadioButton ); - const confirmButton = await screen.findByText( /EXPLORE OBSERVATIONS/ ); - await actor.press( confirmButton ); + await landOnObservationsView( ); const headerCount = await screen.findByText( /1 Observation/ ); expect( headerCount ).toBeVisible( ); const gridView = await screen.findByTestId( "SegmentedButton.grid" ); @@ -312,8 +324,7 @@ describe( "logged in", ( ) => { renderApp( ); await navigateToRootExplore( ); - const speciesViewIcon = await screen.findByLabelText( /Species View/ ); - expect( speciesViewIcon ).toBeVisible( ); + await landOnObservationsView( ); const defaultNearbyLocationText = await screen.findByText( /Nearby/ ); expect( defaultNearbyLocationText ).toBeVisible( ); const backButton = screen.queryByTestId( "Explore.BackButton" ); diff --git a/tests/unit/components/Explore/MapView.test.js b/tests/unit/components/Explore/MapView.test.js index 98ac46dd4..08da149c4 100644 --- a/tests/unit/components/Explore/MapView.test.js +++ b/tests/unit/components/Explore/MapView.test.js @@ -1,87 +1,140 @@ +/* eslint-disable react/jsx-props-no-spreading */ import { screen, userEvent } from "@testing-library/react-native"; -import * as useMapLocation from "components/Explore/hooks/useMapLocation.ts"; import MapView from "components/Explore/MapView.tsx"; -import { ExploreProvider } from "providers/ExploreContext.tsx"; +import { EXPLORE_ACTION, ExploreProvider } from "providers/ExploreContext.tsx"; import React from "react"; import factory from "tests/factory"; import { renderComponent } from "tests/helpers/render"; -const mockData = { }; -jest.mock( "sharedHooks/useAuthenticatedQuery", () => ( { - __esModule: true, - default: () => ( { - data: mockData - } ) -} ) ); +// Mock the useExplore hook with a mock dispatch function +const mockDispatch = jest.fn( ); +const mockDefaultExploreLocation = jest.fn( ).mockResolvedValue( { + lat: 10, + lng: 20 +} ); -const mockRedoSearch = jest.fn( ); -jest.mock( "components/Explore/hooks/useMapLocation", () => ( { - __esModule: true, - default: ( ) => ( { - showMapBoundaryButton: false, - redoSearchInMapArea: mockRedoSearch - } ) -} ) ); +// Create a mock implementation of the ExploreContext +jest.mock( "providers/ExploreContext.tsx", ( ) => { + const originalModule = jest.requireActual( "providers/ExploreContext.tsx" ); + return { + __esModule: true, + ...originalModule, + useExplore: ( ) => ( { + state: { + lat: 10, + lng: 20, + placeMode: originalModule.PLACE_MODE.NEARBY, + place: null + }, + dispatch: mockDispatch, + defaultExploreLocation: mockDefaultExploreLocation + } ) + }; +} ); -jest.mock( "sharedHooks/useLocationPermission.tsx", () => ( { - __esModule: true, - default: ( ) => ( { - hasPermissions: true, - renderPermissionsGate: jest.fn(), - requestPermissions: jest.fn() - } ) -} ) ); +const mockObservationBounds = { + swlat: 10, + swlng: 20, + nelat: 30, + nelng: 40 +}; + +const mockRequestLocationPermissions = jest.fn( ); + +const defaultProps = { + observationBounds: mockObservationBounds, + queryParams: { + taxon_id: 1, + return_bounds: true + }, + isLoading: false, + hasLocationPermissions: true, + renderLocationPermissionsGate: jest.fn( ), + requestLocationPermissions: mockRequestLocationPermissions +}; const mockObservations = [ factory( "RemoteObservation" ), factory( "RemoteObservation" ) ]; -function renderMapView() { - renderComponent( ); +function renderMapView( ) { + renderComponent( ); } -describe( "MapView", () => { - beforeAll( async ( ) => { - // userEvent recommends fake timers - jest.useFakeTimers( ); +const actor = userEvent.setup( ); + +describe( "MapView", ( ) => { + beforeEach( () => { + jest.useFakeTimers(); + } ); + + afterEach( () => { + jest.clearAllMocks(); + jest.clearAllTimers(); } ); it( "should be accessible", ( ) => { const exploreMap = ( - + ); expect( exploreMap ).toBeAccessible( ); } ); - it( "should hide redo search button by default", async ( ) => { - renderMapView(); + it( "should hide redo search button by default", ( ) => { + renderMapView( ); + const redoSearchButton = screen.queryByText( /REDO SEARCH IN MAP AREA/ ); expect( redoSearchButton ).toBeFalsy( ); } ); - it( "should render redo search button", async ( ) => { - jest.spyOn( useMapLocation, "default" ) - .mockImplementation( ( ) => ( { showMapBoundaryButton: true } ) ); - renderMapView(); - const redoSearchButton = screen.queryByText( /REDO SEARCH IN MAP AREA/ ); - expect( redoSearchButton ).toBeVisible( ); + it( "should dispatch SET_EXPLORE_LOCATION when current location button is pressed", async ( ) => { + renderMapView( ); + + const currentLocationButton = screen.getByTestId( "Map.CurrentLocationButton" ); + await actor.press( currentLocationButton ); + + await Promise.resolve( ); + jest.runAllTimers( ); + + expect( mockDefaultExploreLocation ).toHaveBeenCalled( ); + + expect( mockDispatch ).toHaveBeenCalledWith( { + type: EXPLORE_ACTION.SET_EXPLORE_LOCATION, + exploreLocation: { lat: 10, lng: 20 } + } ); } ); - it( "should update map boundaries when redo search is pressed", async ( ) => { - jest.spyOn( useMapLocation, "default" ) - .mockImplementation( ( ) => ( { - showMapBoundaryButton: true, - redoSearchInMapArea: mockRedoSearch - } ) ); - renderMapView(); + it( "should dispatch requestLocationPermissions when current location button " + + " is pressed and user has not given permissions", async ( ) => { + renderComponent( + + + + ); - const actor = await userEvent.setup( ); - const redoSearchButton = screen.queryByText( /REDO SEARCH IN MAP AREA/ ); - await actor.press( redoSearchButton ); + const currentLocationButton = screen.getByTestId( "Map.CurrentLocationButton" ); + await actor.press( currentLocationButton ); - expect( mockRedoSearch ).toHaveBeenCalled( ); + await Promise.resolve( ); + jest.runAllTimers( ); + + expect( mockRequestLocationPermissions ).toHaveBeenCalled( ); + } ); + + it( "should show loading indicator when isLoading is true", ( ) => { + renderComponent( + + + + ); + + const loadingIndicator = screen.getByTestId( "activity-indicator" ); + expect( loadingIndicator ).toBeTruthy( ); } ); } );