diff --git a/src/components/Explore/Explore.js b/src/components/Explore/Explore.js index 4a9b37c0e..9c53bdd3d 100644 --- a/src/components/Explore/Explore.js +++ b/src/components/Explore/Explore.js @@ -48,6 +48,7 @@ type Props = { closeFiltersModal: Function, count: Object, exploreView: string, + hideBackButton: boolean, isOnline: boolean, loadingStatus: boolean, openFiltersModal: Function, @@ -62,6 +63,7 @@ const Explore = ( { closeFiltersModal, count, exploreView, + hideBackButton, isOnline, loadingStatus, openFiltersModal, @@ -76,11 +78,19 @@ const Explore = ( { const { layout, writeLayoutToStorage } = useStoredLayout( "exploreObservationsLayout" ); const { isDebug } = useDebugMode( ); + const exploreViewAccessibilityLabel = { + observations: t( "Observations-View" ), + species: t( "Species-View" ), + observers: t( "Observers-View" ), + identifiers: t( "Identifiers-View" ) + }; + const renderHeader = ( ) => (
setShowExploreBottomSheet( true )} @@ -184,7 +194,7 @@ const Explore = ( { grayCircleClass, "absolute bottom-5 z-10 right-5" )} - accessibilityLabel={t( "Explore-View" )} + accessibilityLabel={exploreViewAccessibilityLabel[exploreView]} onPress={() => setShowExploreBottomSheet( true )} style={getShadow( theme.colors.primary )} /> diff --git a/src/components/Explore/ExploreContainer.js b/src/components/Explore/ExploreContainer.js index f2e61cbb3..905a11e2f 100644 --- a/src/components/Explore/ExploreContainer.js +++ b/src/components/Explore/ExploreContainer.js @@ -2,150 +2,20 @@ import { useRoute } from "@react-navigation/native"; import { - ESTABLISHMENT_MEAN, EXPLORE_ACTION, ExploreProvider, - MEDIA, - PHOTO_LICENSE, - REVIEWED, - SORT_BY, - useExplore, - WILD_STATUS + useExplore } from "providers/ExploreContext.tsx"; import type { Node } from "react"; import React, { useEffect, useState } from "react"; -import { useCurrentUser, useIsConnected } from "sharedHooks"; +import { useCurrentUser, useIsConnected, useTranslation } from "sharedHooks"; import Explore from "./Explore"; +import mapParamsToAPI from "./helpers/mapParamsToAPI"; import useHeaderCount from "./hooks/useHeaderCount"; -const mapParamsToAPI = ( params, currentUser ) => { - const RESEARCH = "research"; - const NEEDS_ID = "needs_id"; - const CASUAL = "casual"; - - const CREATED_AT = "created_at"; // = date uploaded at - const OBSERVED_ON = "observed_on"; - const VOTES = "votes"; - - const DESC = "desc"; - const ASC = "asc"; - - // Remove all params that are falsy - const filteredParams = Object.entries( params ).reduce( - ( newParams, [key, value] ) => { - if ( value ) { - newParams[key] = value; - } - return newParams; - }, - {} - ); - - // DATE_UPLOADED_NEWEST is the default sort order - filteredParams.order_by = CREATED_AT; - filteredParams.order = DESC; - if ( params.sortBy === SORT_BY.DATE_UPLOADED_OLDEST ) { - filteredParams.order_by = CREATED_AT; - filteredParams.order = ASC; - } - if ( params.sortBy === SORT_BY.DATE_OBSERVED_NEWEST ) { - filteredParams.order_by = OBSERVED_ON; - filteredParams.order = DESC; - } - if ( params.sortBy === SORT_BY.DATE_OBSERVED_OLDEST ) { - filteredParams.order_by = OBSERVED_ON; - filteredParams.order = ASC; - } - if ( params.sortBy === SORT_BY.MOST_FAVED ) { - filteredParams.order_by = VOTES; - filteredParams.order = DESC; - } - - filteredParams.quality_grade = []; - if ( params.researchGrade ) { - filteredParams.quality_grade.push( RESEARCH ); - } - if ( params.needsID ) { - filteredParams.quality_grade.push( NEEDS_ID ); - } - if ( params.casual ) { - filteredParams.quality_grade.push( CASUAL ); - delete filteredParams.verifiable; - } - - if ( filteredParams.months ) { - filteredParams.month = filteredParams.months; - delete filteredParams.months; - } - - // MEDIA.ALL is the default media filter and for it we don't need to pass any params - if ( params.media === MEDIA.PHOTOS ) { - filteredParams.photos = true; - } else if ( params.media === MEDIA.SOUNDS ) { - filteredParams.sounds = true; - } else if ( params.media === MEDIA.NONE ) { - filteredParams.photos = false; - filteredParams.sounds = false; - } - - // ESTABLISHMENT_MEAN.ANY is the default here and for it we don't need to pass any params - if ( params.establishmentMean === ESTABLISHMENT_MEAN.NATIVE ) { - filteredParams.native = true; - } else if ( params.establishmentMean === ESTABLISHMENT_MEAN.INTRODUCED ) { - filteredParams.introduced = true; - } else if ( params.establishmentMean === ESTABLISHMENT_MEAN.ENDEMIC ) { - filteredParams.endemic = true; - } - - if ( params.wildStatus === WILD_STATUS.WILD ) { - filteredParams.captive = false; - } else if ( params.wildStatus === WILD_STATUS.CAPTIVE ) { - filteredParams.captive = true; - } - - if ( params.reviewedFilter === REVIEWED.REVIEWED ) { - filteredParams.reviewed = true; - filteredParams.viewer_id = currentUser?.id; - } else if ( params.reviewedFilter === REVIEWED.UNREVIEWED ) { - filteredParams.reviewed = false; - filteredParams.viewer_id = currentUser?.id; - } - - if ( params.photoLicense !== PHOTO_LICENSE.ALL ) { - // How license filter maps to the API - const licenseParams = { - [PHOTO_LICENSE.CC0]: "cc0", - [PHOTO_LICENSE.CCBY]: "cc-by", - [PHOTO_LICENSE.CCBYNC]: "cc-by-nc", - [PHOTO_LICENSE.CCBYSA]: "cc-by-sa", - [PHOTO_LICENSE.CCBYND]: "cc-by-nd", - [PHOTO_LICENSE.CCBYNCSA]: "cc-by-nc-sa", - [PHOTO_LICENSE.CCBYNCND]: "cc-by-nc-nd" - }; - filteredParams.photo_license = licenseParams[params.photoLicense]; - } - - delete filteredParams.taxon; - delete filteredParams.place_guess; - delete filteredParams.user; - delete filteredParams.project; - delete filteredParams.sortBy; - delete filteredParams.researchGrade; - delete filteredParams.needsID; - delete filteredParams.casual; - delete filteredParams.dateObserved; - delete filteredParams.dateUploaded; - delete filteredParams.media; - delete filteredParams.establishmentMean; - delete filteredParams.wildStatus; - delete filteredParams.reviewedFilter; - delete filteredParams.photoLicense; - - return filteredParams; -}; - const ExploreContainerWithContext = ( ): Node => { + const { t } = useTranslation( ); const { params } = useRoute( ); const isOnline = useIsConnected( ); @@ -156,6 +26,8 @@ const ExploreContainerWithContext = ( ): Node => { const [showFiltersModal, setShowFiltersModal] = useState( false ); const [exploreView, setExploreView] = useState( "observations" ); + const worldwidePlaceText = t( "Worldwide" ); + useEffect( ( ) => { if ( params?.viewSpecies ) { setExploreView( "species" ); @@ -164,7 +36,7 @@ const ExploreContainerWithContext = ( ): Node => { dispatch( { type: EXPLORE_ACTION.SET_PLACE, placeId: null, - placeName: "" + placeName: worldwidePlaceText } ); } if ( params?.taxon ) { @@ -196,7 +68,7 @@ const ExploreContainerWithContext = ( ): Node => { projectId: params.project.id } ); } - }, [params, dispatch] ); + }, [params, dispatch, worldwidePlaceText] ); const changeExploreView = newView => { setExploreView( newView ); @@ -240,6 +112,7 @@ const ExploreContainerWithContext = ( ): Node => { closeFiltersModal={closeFiltersModal} count={count} exploreView={exploreView} + hideBackButton={false} isOnline={isOnline} loadingStatus={loadingStatus} openFiltersModal={openFiltersModal} diff --git a/src/components/Explore/Header/Header.js b/src/components/Explore/Header/Header.js index 4d91d8bc3..7bd677cb2 100644 --- a/src/components/Explore/Header/Header.js +++ b/src/components/Explore/Header/Header.js @@ -4,6 +4,7 @@ import { useNavigation } from "@react-navigation/native"; import classNames from "classnames"; import NumberBadge from "components/Explore/NumberBadge.tsx"; import { + BackButton, Body3, INatIcon, INatIconButton, @@ -23,6 +24,7 @@ type Props = { count: ?number, exploreView: string, exploreViewIcon: string, + hideBackButton: boolean, loadingStatus: boolean, onPressCount?: Function, openFiltersModal: Function @@ -32,6 +34,7 @@ const Header = ( { count, exploreView, exploreViewIcon, + hideBackButton, loadingStatus, onPressCount, openFiltersModal @@ -41,7 +44,7 @@ const Header = ( { const theme = useTheme( ); const { state, numberOfFilters } = useExplore( ); const { taxon } = state; - const placeName = state.place_guess || t( "Worldwide" ); + const placeName = state.place_guess; const surfaceStyle = { backgroundColor: theme.colors.primary, @@ -54,35 +57,45 @@ const Header = ( { - - {taxon + + {!hideBackButton ? ( - navigation.navigate( "ExploreTaxonSearch" )} + ) - : ( - navigation.navigate( "ExploreTaxonSearch" )} - > - - {t( "All-organisms" )} - - )} - navigation.navigate( "ExploreLocationSearch" )} - className="flex-row items-center pt-3" - > - - {placeName} - + : } + + {taxon + ? ( + navigation.navigate( "ExploreTaxonSearch" )} + /> + ) + : ( + navigation.navigate( "ExploreTaxonSearch" )} + > + + {t( "All-organisms" )} + + )} + navigation.navigate( "ExploreLocationSearch" )} + className="flex-row items-center pt-3" + > + + {placeName} + + { + const { t } = useTranslation( ); + const isOnline = useIsConnected( ); + const currentUser = useCurrentUser( ); + + const { state, dispatch, makeSnapshot } = useExplore( ); + + const [showFiltersModal, setShowFiltersModal] = useState( false ); + const [exploreView, setExploreView] = useState( "species" ); + + const changeExploreView = newView => { + setExploreView( newView ); + }; + + const updateTaxon = ( taxon: Object ) => { + dispatch( { + type: EXPLORE_ACTION.CHANGE_TAXON, + taxon, + taxonId: taxon?.id, + taxonName: taxon?.preferred_common_name || taxon?.name + } ); + }; + + const filteredParams = mapParamsToAPI( + state, + currentUser + ); + + const queryParams = { + ...filteredParams, + per_page: 20 + }; + if ( exploreView === "observers" ) { + queryParams.order_by = "observation_count"; + } + + // need this hook to be top-level enough that HeaderCount rerenders + const { count, loadingStatus, updateCount } = useHeaderCount( ); + + const closeFiltersModal = ( ) => setShowFiltersModal( false ); + + const openFiltersModal = ( ) => { + setShowFiltersModal( true ); + makeSnapshot( ); + }; + + const onPermissionGranted = async ( ) => { + if ( state.place_guess ) { return; } + const location = await fetchUserLocation( ); + console.log( location, "location in onPermissionGranted" ); + if ( !location || !location.latitude ) { + dispatch( { + type: EXPLORE_ACTION.SET_PLACE, + placeName: t( "Worldwide" ) + } ); + } else { + dispatch( { + type: EXPLORE_ACTION.SET_PLACE, + placeName: t( "Nearby" ), + lat: location?.latitude, + lng: location?.longitude, + radius: 50 + } ); + } + }; + + const onPermissionDenied = ( ) => { + if ( state.place_guess ) { return; } + dispatch( { + type: EXPLORE_ACTION.SET_PLACE, + placeName: t( "Worldwide" ) + } ); + }; + + const onPermissionBlocked = ( ) => { + if ( state.place_guess ) { return; } + dispatch( { + type: EXPLORE_ACTION.SET_PLACE, + placeName: t( "Worldwide" ) + } ); + }; + + return ( + <> + + + + ); +}; + +const ExploreContainer = (): Node => ( + + + +); + +export default ExploreContainer; diff --git a/src/components/Explore/helpers/mapParamsToAPI.js b/src/components/Explore/helpers/mapParamsToAPI.js new file mode 100644 index 000000000..d2485901c --- /dev/null +++ b/src/components/Explore/helpers/mapParamsToAPI.js @@ -0,0 +1,138 @@ +// @flow + +import { + ESTABLISHMENT_MEAN, + MEDIA, + PHOTO_LICENSE, + REVIEWED, + SORT_BY, + WILD_STATUS +} from "providers/ExploreContext.tsx"; + +const mapParamsToAPI = ( params: Object, currentUser: Object ): Object => { + const RESEARCH = "research"; + const NEEDS_ID = "needs_id"; + const CASUAL = "casual"; + + const CREATED_AT = "created_at"; // = date uploaded at + const OBSERVED_ON = "observed_on"; + const VOTES = "votes"; + + const DESC = "desc"; + const ASC = "asc"; + + // Remove all params that are falsy + const filteredParams = Object.entries( params ).reduce( + ( newParams, [key, value] ) => { + if ( value ) { + newParams[key] = value; + } + return newParams; + }, + {} + ); + + // DATE_UPLOADED_NEWEST is the default sort order + filteredParams.order_by = CREATED_AT; + filteredParams.order = DESC; + if ( params.sortBy === SORT_BY.DATE_UPLOADED_OLDEST ) { + filteredParams.order_by = CREATED_AT; + filteredParams.order = ASC; + } + if ( params.sortBy === SORT_BY.DATE_OBSERVED_NEWEST ) { + filteredParams.order_by = OBSERVED_ON; + filteredParams.order = DESC; + } + if ( params.sortBy === SORT_BY.DATE_OBSERVED_OLDEST ) { + filteredParams.order_by = OBSERVED_ON; + filteredParams.order = ASC; + } + if ( params.sortBy === SORT_BY.MOST_FAVED ) { + filteredParams.order_by = VOTES; + filteredParams.order = DESC; + } + + filteredParams.quality_grade = []; + if ( params.researchGrade ) { + filteredParams.quality_grade.push( RESEARCH ); + } + if ( params.needsID ) { + filteredParams.quality_grade.push( NEEDS_ID ); + } + if ( params.casual ) { + filteredParams.quality_grade.push( CASUAL ); + delete filteredParams.verifiable; + } + + if ( filteredParams.months ) { + filteredParams.month = filteredParams.months; + delete filteredParams.months; + } + + // MEDIA.ALL is the default media filter and for it we don't need to pass any params + if ( params.media === MEDIA.PHOTOS ) { + filteredParams.photos = true; + } else if ( params.media === MEDIA.SOUNDS ) { + filteredParams.sounds = true; + } else if ( params.media === MEDIA.NONE ) { + filteredParams.photos = false; + filteredParams.sounds = false; + } + + // ESTABLISHMENT_MEAN.ANY is the default here and for it we don't need to pass any params + if ( params.establishmentMean === ESTABLISHMENT_MEAN.NATIVE ) { + filteredParams.native = true; + } else if ( params.establishmentMean === ESTABLISHMENT_MEAN.INTRODUCED ) { + filteredParams.introduced = true; + } else if ( params.establishmentMean === ESTABLISHMENT_MEAN.ENDEMIC ) { + filteredParams.endemic = true; + } + + if ( params.wildStatus === WILD_STATUS.WILD ) { + filteredParams.captive = false; + } else if ( params.wildStatus === WILD_STATUS.CAPTIVE ) { + filteredParams.captive = true; + } + + if ( params.reviewedFilter === REVIEWED.REVIEWED ) { + filteredParams.reviewed = true; + filteredParams.viewer_id = currentUser?.id; + } else if ( params.reviewedFilter === REVIEWED.UNREVIEWED ) { + filteredParams.reviewed = false; + filteredParams.viewer_id = currentUser?.id; + } + + if ( params.photoLicense !== PHOTO_LICENSE.ALL ) { + // How license filter maps to the API + const licenseParams = { + [PHOTO_LICENSE.CC0]: "cc0", + [PHOTO_LICENSE.CCBY]: "cc-by", + [PHOTO_LICENSE.CCBYNC]: "cc-by-nc", + [PHOTO_LICENSE.CCBYSA]: "cc-by-sa", + [PHOTO_LICENSE.CCBYND]: "cc-by-nd", + [PHOTO_LICENSE.CCBYNCSA]: "cc-by-nc-sa", + [PHOTO_LICENSE.CCBYNCND]: "cc-by-nc-nd" + }; + filteredParams.photo_license = licenseParams[params.photoLicense]; + } + + delete filteredParams.taxon; + delete filteredParams.place_guess; + delete filteredParams.user; + delete filteredParams.project; + delete filteredParams.sortBy; + delete filteredParams.researchGrade; + delete filteredParams.needsID; + delete filteredParams.casual; + delete filteredParams.dateObserved; + delete filteredParams.dateUploaded; + delete filteredParams.media; + delete filteredParams.establishmentMean; + delete filteredParams.wildStatus; + delete filteredParams.reviewedFilter; + delete filteredParams.photoLicense; + + return filteredParams; +}; + +export default mapParamsToAPI; diff --git a/src/components/Explore/hooks/useMapLocation.js b/src/components/Explore/hooks/useMapLocation.js index 8838af9d8..aa285a39c 100644 --- a/src/components/Explore/hooks/useMapLocation.js +++ b/src/components/Explore/hooks/useMapLocation.js @@ -20,6 +20,7 @@ const DELTA = 0.2; const useMapLocation = ( ): Object => { const { params } = useRoute( ); const place = params?.place; + const worldwide = params?.worldwide; const realm = useRealm( ); const { dispatch, state } = useExplore( ); const [mapBoundaries, setMapBoundaries] = useState( null ); @@ -31,7 +32,7 @@ const useMapLocation = ( ): Object => { latitudeDelta: DELTA, longitudeDelta: DELTA } ); - const [startAtNearby, setStartAtNearby] = useState( !state.swlat ); + const [startAtNearby, setStartAtNearby] = useState( !state.swlat && !worldwide ); const { t } = useTranslation( ); const onPanDrag = ( ) => setShowMapBoundaryButton( true ); diff --git a/src/components/MyObservations/MyObservationsEmpty.js b/src/components/MyObservations/MyObservationsEmpty.js index e17680498..0f652662c 100644 --- a/src/components/MyObservations/MyObservationsEmpty.js +++ b/src/components/MyObservations/MyObservationsEmpty.js @@ -54,8 +54,8 @@ const MyObservationsEmpty = ( { isFetchingNextPage }: Props ): Node => { className="mb-2" text={t( "EXPLORE-OBSERVATIONS" )} level="focus" - onPress={( ) => navigation.navigate( "Explore" )} - accessibilityLabel={t( "Explore" )} + onPress={( ) => navigation.navigate( "RootExplore" )} + accessibilityLabel={t( "See-observations-in-explore" )} accessibilityHint={t( "Navigates-to-explore" )} /> diff --git a/src/components/MyObservations/Toolbar.js b/src/components/MyObservations/Toolbar.js index 12a226daf..6884ab357 100644 --- a/src/components/MyObservations/Toolbar.js +++ b/src/components/MyObservations/Toolbar.js @@ -62,7 +62,7 @@ const Toolbar = ( { ( - + { layout === "grid" ? ( diff --git a/src/components/SharedComponents/OverviewCounts.js b/src/components/SharedComponents/OverviewCounts.js index 57e1686d5..d6d5b4b0c 100644 --- a/src/components/SharedComponents/OverviewCounts.js +++ b/src/components/SharedComponents/OverviewCounts.js @@ -16,14 +16,15 @@ type Props = { type CountProps = { count: number, - label: string, - icon: string + icon: string, + label: string } type CountPressableProps = { + accessibilityLabel: string, count: number, - label: string, icon: string, + label: string, onPress?: Function } @@ -48,12 +49,16 @@ const Count = ( { ); const CountPressable = ( { - count, label, icon, onPress + accessibilityLabel, + count, + icon, + label, + onPress }: CountPressableProps ) => ( @@ -75,12 +80,14 @@ const OverviewCounts = ( { }: Props ): React.Node => ( searchObservations( { - user_id: currentUser.id, + user_id: currentUser?.id, per_page: 0, taxon_id: taxonId }, optsWithAuth ), { - keepPreviousData: false + keepPreviousData: false, + enabled: !!taxonId && !!currentUser?.id } ); diff --git a/src/components/TaxonDetails/TaxonDetails.js b/src/components/TaxonDetails/TaxonDetails.js index 57c80004e..a3d00f813 100644 --- a/src/components/TaxonDetails/TaxonDetails.js +++ b/src/components/TaxonDetails/TaxonDetails.js @@ -219,7 +219,7 @@ const TaxonDetails = ( ): Node => { params: { taxon, worldwide: true } } } )} - accessibilityLabel={t( "Explore" )} + accessibilityLabel={t( "See-observations-of-this-taxon-in-explore" )} accessibilityHint={t( "Navigates-to-explore" )} size={30} color={theme.colors.onPrimary} diff --git a/src/components/UserProfile/UserProfile.js b/src/components/UserProfile/UserProfile.js index 4192d9949..91d0b5b51 100644 --- a/src/components/UserProfile/UserProfile.js +++ b/src/components/UserProfile/UserProfile.js @@ -54,7 +54,7 @@ const UserProfile = ( ): Node => { }, optsWithAuth ) ); let relationshipResults = null; - if ( relationships?.results ) { + if ( relationships?.results && relationships.results.length > 0 ) { relationshipResults = relationships?.results .find( relationship => relationship.friendUser.id === userId ); } diff --git a/src/i18n/l10n/en.ftl b/src/i18n/l10n/en.ftl index e26c35fd0..8755da9de 100644 --- a/src/i18n/l10n/en.ftl +++ b/src/i18n/l10n/en.ftl @@ -1840,4 +1840,27 @@ Sorry-this-observation-was-deleted = Sorry, this observation was deleted # Generic confirmation, e.g. button on a warning alert OK = OK -Connection-problem-Please-try-again-later = Connection problem. Please try again later. +Connection-problem-please-try-again-later = Connection problem. Please try again later. + +# Accessibility label for Explore button on bottom tab navigator +Navigate-to-explore-screen = Navigate to explore screen + +# Accessibility label for Explore button on MyObservations toolbar +See-all-your-observations-in-explore = See all your observations in explore + +Observations-View = Observations View +Species-View = Species View +Observers-View = Observers View +Identifiers-View = Identifiers View + +# Accessibility label for Species button on UserProfile screen +See-species-observed-by-this-user-in-Explore = See species observed by this user in Explore + +# Accessibility label for Observations button on UserProfile screen +See-observations-by-this-user-in-Explore = See observations by this user in Explore + +# Accessibility label for Explore button on TaxonDetails screen +See-observations-of-this-taxon-in-explore = See observations of this taxon in explore + +# Accessibility label for Explore button in MyObservationsEmpty for logged out user +See-observations-in-explore = See observations in explore \ No newline at end of file diff --git a/src/i18n/l10n/en.ftl.json b/src/i18n/l10n/en.ftl.json index 588beea47..40b25145f 100644 --- a/src/i18n/l10n/en.ftl.json +++ b/src/i18n/l10n/en.ftl.json @@ -1345,5 +1345,33 @@ "comment": "Generic confirmation, e.g. button on a warning alert", "val": "OK" }, - "Connection-problem-Please-try-again-later": "Connection problem. Please try again later." + "Connection-problem-please-try-again-later": "Connection problem. Please try again later.", + "Navigate-to-explore-screen": { + "comment": "Accessibility label for Explore button on bottom tab navigator", + "val": "Navigate to explore screen" + }, + "See-all-your-observations-in-explore": { + "comment": "Accessibility label for Explore button on MyObservations toolbar", + "val": "See all your observations in explore" + }, + "Observations-View": "Observations View", + "Species-View": "Species View", + "Observers-View": "Observers View", + "Identifiers-View": "Identifiers View", + "See-species-observed-by-this-user-in-Explore": { + "comment": "Accessibility label for Species button on UserProfile screen", + "val": "See species observed by this user in Explore" + }, + "See-observations-by-this-user-in-Explore": { + "comment": "Accessibility label for Observations button on UserProfile screen", + "val": "See observations by this user in Explore" + }, + "See-observations-of-this-taxon-in-explore": { + "comment": "Accessibility label for Explore button on TaxonDetails screen", + "val": "See observations of this taxon in explore" + }, + "See-observations-in-explore": { + "comment": "Accessibility label for Explore button in MyObservationsEmpty for logged out user", + "val": "See observations in explore" + } } diff --git a/src/i18n/strings.ftl b/src/i18n/strings.ftl index e26c35fd0..8755da9de 100644 --- a/src/i18n/strings.ftl +++ b/src/i18n/strings.ftl @@ -1840,4 +1840,27 @@ Sorry-this-observation-was-deleted = Sorry, this observation was deleted # Generic confirmation, e.g. button on a warning alert OK = OK -Connection-problem-Please-try-again-later = Connection problem. Please try again later. +Connection-problem-please-try-again-later = Connection problem. Please try again later. + +# Accessibility label for Explore button on bottom tab navigator +Navigate-to-explore-screen = Navigate to explore screen + +# Accessibility label for Explore button on MyObservations toolbar +See-all-your-observations-in-explore = See all your observations in explore + +Observations-View = Observations View +Species-View = Species View +Observers-View = Observers View +Identifiers-View = Identifiers View + +# Accessibility label for Species button on UserProfile screen +See-species-observed-by-this-user-in-Explore = See species observed by this user in Explore + +# Accessibility label for Observations button on UserProfile screen +See-observations-by-this-user-in-Explore = See observations by this user in Explore + +# Accessibility label for Explore button on TaxonDetails screen +See-observations-of-this-taxon-in-explore = See observations of this taxon in explore + +# Accessibility label for Explore button in MyObservationsEmpty for logged out user +See-observations-in-explore = See observations in explore \ No newline at end of file diff --git a/src/navigation/BottomTabNavigator/CustomTabBarContainer.js b/src/navigation/BottomTabNavigator/CustomTabBarContainer.js index e108ca12b..036af3390 100644 --- a/src/navigation/BottomTabNavigator/CustomTabBarContainer.js +++ b/src/navigation/BottomTabNavigator/CustomTabBarContainer.js @@ -8,7 +8,7 @@ import { useCurrentUser, useTranslation } from "sharedHooks"; import CustomTabBar from "./CustomTabBar"; const DRAWER_ID = "OPEN_DRAWER"; -const EXPLORE_SCREEN_ID = "Explore"; +const EXPLORE_SCREEN_ID = "RootExplore"; const OBS_LIST_SCREEN_ID = "ObservationsStackNavigator"; const NOTIFICATIONS_SCREEN_ID = "Notifications"; @@ -38,13 +38,11 @@ const CustomTabBarContainer = ( { navigation }: Props ): Node => { { icon: "compass-rose-outline", testID: EXPLORE_SCREEN_ID, - accessibilityLabel: t( "Explore" ), + accessibilityLabel: t( "Navigate-to-explore-screen" ), accessibilityHint: t( "Navigates-to-explore" ), size: 40, onPress: ( ) => { - navigation.navigate( "ObservationsStackNavigator", { - screen: "Explore" - } ); + navigation.navigate( "RootExplore" ); setActiveTab( EXPLORE_SCREEN_ID ); }, active: EXPLORE_SCREEN_ID === activeTab diff --git a/src/navigation/StackNavigators/ObservationsStackNavigator.js b/src/navigation/StackNavigators/ObservationsStackNavigator.js index aebc089dc..bdbf7fd9e 100644 --- a/src/navigation/StackNavigators/ObservationsStackNavigator.js +++ b/src/navigation/StackNavigators/ObservationsStackNavigator.js @@ -3,6 +3,7 @@ // eslint-disable-next-line import/no-extraneous-dependencies import { createNativeStackNavigator } from "@react-navigation/native-stack"; import ExploreContainer from "components/Explore/ExploreContainer"; +import RootExploreContainer from "components/Explore/RootExploreContainer"; import ExploreLocationSearch from "components/Explore/SearchScreens/ExploreLocationSearch"; import ExploreProjectSearch from "components/Explore/SearchScreens/ExploreProjectSearch"; import ExploreTaxonSearch from "components/Explore/SearchScreens/ExploreTaxonSearch"; @@ -90,6 +91,11 @@ const ObservationsStackNavigator = ( ): Node => ( {SharedStackScreens( )} + false ), + wasSynced: jest.fn( ( ) => true ), + user: mockUser, + taxon: mockTaxon + } ) +]; + +const obs = mockObservations[0]; + +const mockFetchUserLocation = jest.fn( () => ( { latitude: 37, longitude: 34 } ) ); +jest.mock( "sharedHelpers/fetchUserLocation", () => ( { + __esModule: true, + default: () => mockFetchUserLocation() +} ) ); + +// UNIQUE REALM SETUP +const mockRealmIdentifier = __filename; +const { mockRealmModelsIndex, uniqueRealmBeforeAll, uniqueRealmAfterAll } = setupUniqueRealm( + mockRealmIdentifier +); +jest.mock( "realmModels/index", ( ) => mockRealmModelsIndex ); +jest.mock( "providers/contexts", ( ) => { + const originalModule = jest.requireActual( "providers/contexts" ); + return { + __esModule: true, + ...originalModule, + RealmContext: { + ...originalModule.RealmContext, + useRealm: ( ) => global.mockRealms[mockRealmIdentifier] + } + }; +} ); +beforeAll( uniqueRealmBeforeAll ); +afterAll( uniqueRealmAfterAll ); +// /UNIQUE REALM SETUP + +beforeAll( async () => { + await initI18next(); + jest.useFakeTimers( ); + inatjs.observations.speciesCounts.mockResolvedValue( makeResponse( [{ + count: 1, + taxon: mockTaxon + }] ) ); + inatjs.observations.search.mockResolvedValue( makeResponse( mockObservations ) ); + inatjs.taxa.fetch.mockResolvedValue( makeResponse( [mockTaxon] ) ); +} ); + +const actor = userEvent.setup( ); + +async function navigateToObsDetails( ) { + expect( await screen.findByText( /Welcome back/ ) ).toBeVisible( ); + const firstObservation = await screen.findByTestId( `MyObservationsPressable.${obs.uuid}` ); + await actor.press( firstObservation ); +} + +async function navigateToRootExplore( ) { + expect( await screen.findByText( /Welcome back/ ) ).toBeVisible( ); + const exploreButton = await screen.findByLabelText( /Navigate to explore screen/ ); + await actor.press( exploreButton ); +} + +describe( "logged out", ( ) => { + describe( "from MyObservationsEmpty for logged out user", ( ) => { + it( "should display species view with no back button", async ( ) => { + renderApp( ); + expect( await screen.findByText( /Log in to contribute/ ) ).toBeVisible( ); + const exploreButton = await screen.findByLabelText( /See observations in explore/ ); + await actor.press( exploreButton ); + const speciesViewIcon = await screen.findByLabelText( /Species View/ ); + expect( speciesViewIcon ).toBeVisible( ); + const backButton = screen.queryByTestId( "Explore.BackButton" ); + expect( backButton ).toBeFalsy( ); + } ); + } ); +} ); + +describe( "logged in", ( ) => { + beforeEach( async ( ) => { + // Write mock observation to realm + safeRealmWrite( global.mockRealms[__filename], ( ) => { + global.mockRealms[__filename].create( "Observation", mockObservations[0] ); + }, "write mock observation, navigation/Explore test" ); + + await signIn( mockUser, { realm: global.mockRealms[__filename] } ); + } ); + + afterEach( ( ) => { + signOut( { realm: global.mockRealms[__filename] } ); + } ); + + describe( "from MyObs", ( ) => { + describe( "from MyObs toolbar", ( ) => { + it( "should show observations view and navigate back to MyObs", async ( ) => { + renderApp( ); + expect( await screen.findByText( /Welcome back/ ) ).toBeVisible( ); + const exploreButton = await screen.findByLabelText( /See all your observations in explore/ ); + await actor.press( exploreButton ); + expect( inatjs.observations.search ).toHaveBeenCalledWith( expect.objectContaining( { + user_id: mockUser.id, + verifiable: true + } ), { + api_token: TEST_JWT + } ); + const defaultGlobalLocation = await screen.findByText( /Worldwide/ ); + expect( defaultGlobalLocation ).toBeVisible( ); + const observationsViewIcon = await screen.findByLabelText( /Observations View/ ); + expect( observationsViewIcon ).toBeVisible( ); + const backButton = screen.queryByTestId( "Explore.BackButton" ); + await actor.press( backButton ); + expect( await screen.findByText( /Welcome back/ ) ).toBeVisible( ); + } ); + } ); + + describe( "from TaxonDetails", ( ) => { + it( "should show observations view and navigate back to TaxonDetails", async ( ) => { + renderApp( ); + await navigateToObsDetails( ); + const taxonPressable = await screen.findByTestId( `ObsDetails.taxon.${obs.taxon.id}` ); + await actor.press( taxonPressable ); + const exploreButton = await screen.findByLabelText( /See observations of this taxon in explore/ ); + await actor.press( exploreButton ); + expect( inatjs.observations.search ).toHaveBeenCalledWith( expect.objectContaining( { + taxon_id: mockTaxon.id, + verifiable: true + } ), { + api_token: TEST_JWT + } ); + const defaultGlobalLocation = await screen.findByText( /Worldwide/ ); + expect( defaultGlobalLocation ).toBeVisible( ); + const observationsViewIcon = await screen.findByLabelText( /Observations View/ ); + expect( observationsViewIcon ).toBeVisible( ); + const backButton = screen.queryByTestId( "Explore.BackButton" ); + await actor.press( backButton ); + expect( exploreButton ).toBeVisible( ); + } ); + } ); + + describe( "from UserProfile observations button", ( ) => { + beforeEach( async ( ) => { + inatjs.users.fetch.mockResolvedValue( makeResponse( [mockUser] ) ); + inatjs.relationships.search.mockResolvedValue( makeResponse( { + results: [] + } ) ); + } ); + + it( "should show observations view and navigate back to UserProfile", async ( ) => { + renderApp( ); + await navigateToObsDetails( ); + const userProfileButton = await screen.findByLabelText( `User @${obs.user.login}` ); + await actor.press( userProfileButton ); + expect( inatjs.users.fetch ).toHaveBeenCalled( ); + const observationsButton = await screen.findByLabelText( /See observations by this user in Explore/ ); + await actor.press( observationsButton ); + expect( inatjs.observations.search ).toHaveBeenCalledWith( expect.objectContaining( { + user_id: mockUser.id, + verifiable: true + } ), { + api_token: TEST_JWT + } ); + const defaultGlobalLocation = await screen.findByText( /Worldwide/ ); + expect( defaultGlobalLocation ).toBeVisible( ); + const observationsViewIcon = await screen.findByLabelText( /Observations View/ ); + expect( observationsViewIcon ).toBeVisible( ); + const backButton = await screen.findByTestId( "Explore.BackButton" ); + await actor.press( backButton ); + const journalPostsButton = await screen.findByText( /JOURNAL POSTS/ ); + await actor.press( journalPostsButton ); + } ); + } ); + + describe( "from UserProfile species button", ( ) => { + beforeEach( async ( ) => { + inatjs.users.fetch.mockResolvedValue( makeResponse( [mockUser] ) ); + inatjs.relationships.search.mockResolvedValue( makeResponse( { + results: [] + } ) ); + } ); + + it( "should show species view and navigate back to UserProfile", async ( ) => { + renderApp( ); + await navigateToObsDetails( ); + const userProfileButton = await screen.findByLabelText( `User @${obs.user.login}` ); + await actor.press( userProfileButton ); + expect( inatjs.users.fetch ).toHaveBeenCalled( ); + const speciesButton = await screen.findByLabelText( /See species observed by this user in Explore/ ); + await actor.press( speciesButton ); + expect( inatjs.observations.speciesCounts ).toHaveBeenCalledWith( expect.objectContaining( { + user_id: mockUser.id, + verifiable: true + } ) ); + const defaultGlobalLocation = await screen.findByText( /Worldwide/ ); + expect( defaultGlobalLocation ).toBeVisible( ); + const speciesViewIcon = await screen.findByLabelText( /Species View/ ); + expect( speciesViewIcon ).toBeVisible( ); + const backButton = await screen.findByTestId( "Explore.BackButton" ); + await actor.press( backButton ); + const journalPostsButton = await screen.findByText( /JOURNAL POSTS/ ); + await actor.press( journalPostsButton ); + } ); + } ); + } ); + + describe( "from bottom tab navigator Explore button", ( ) => { + describe( "from RootExplore -> X -> Explore -> back", ( ) => { + 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( ); + 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/ ); + await actor.press( taxonDetailsExploreButton ); + const defaultGlobalLocation = await screen.findByText( /Worldwide/ ); + expect( defaultGlobalLocation ).toBeVisible( ); + const observationsViewIcon = await screen.findByLabelText( /Observations View/ ); + expect( observationsViewIcon ).toBeVisible( ); + const backButton = screen.queryByTestId( "Explore.BackButton" ); + await actor.press( backButton ); + expect( taxonDetailsExploreButton ).toBeVisible( ); + } ); + + it( "should navigate from UserProfile to Explore and back to UserProfile", async ( ) => { + inatjs.users.fetch.mockResolvedValue( makeResponse( [mockUser] ) ); + inatjs.relationships.search.mockResolvedValue( makeResponse( { + results: [] + } ) ); + inatjs.observations.fetch.mockResolvedValue( makeResponse( mockObservations[0] ) ); + renderApp( ); + await navigateToRootExplore( ); + 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 confirmButton = await screen.findByText( /EXPLORE OBSERVATIONS/ ); + await actor.press( confirmButton ); + const gridView = await screen.findByTestId( "SegmentedButton.grid" ); + await actor.press( gridView ); + const firstObservation = await screen.findByTestId( `MyObservationsPressable.${obs.uuid}` ); + await actor.press( firstObservation ); + const userProfileButton = await screen.findByLabelText( `User @${mockUser.login}` ); + await actor.press( userProfileButton ); + const observationsButton = await screen.findByLabelText( /See observations by this user in Explore/ ); + await actor.press( observationsButton ); + expect( inatjs.observations.search ).toHaveBeenCalledWith( expect.objectContaining( { + user_id: mockUser.id, + verifiable: true + } ), { + api_token: TEST_JWT + } ); + const observationsViewIcon = await screen.findByLabelText( /Observations View/ ); + expect( observationsViewIcon ).toBeVisible( ); + const backButton = screen.queryByTestId( "Explore.BackButton" ); + await actor.press( backButton ); + expect( observationsButton ).toBeVisible( ); + } ); + } ); + + describe( "with location permissions", ( ) => { + it( "should default to nearby location", async ( ) => { + const mockedPermissions = { + "ios.permission.LOCATION": "granted" + }; + + jest.spyOn( ReactNativePermissions, "checkMultiple" ) + .mockResolvedValueOnce( mockedPermissions ); + renderApp( ); + await navigateToRootExplore( ); + const speciesViewIcon = await screen.findByLabelText( /Species View/ ); + expect( speciesViewIcon ).toBeVisible( ); + const nearbyText = await screen.findByText( /Nearby/ ); + expect( nearbyText ).toBeVisible( ); + } ); + } ); + + describe( "without location permissions", ( ) => { + it( "should default to global species view and not have a back button", async ( ) => { + const mockedPermissions = { + "ios.permission.LOCATION": "denied" + }; + + jest.spyOn( ReactNativePermissions, "checkMultiple" ) + .mockResolvedValueOnce( mockedPermissions ); + + renderApp( ); + expect( await screen.findByText( /Welcome back/ ) ).toBeVisible( ); + const exploreButton = await screen.findByLabelText( /Navigate to explore screen/ ); + await actor.press( exploreButton ); + const speciesViewIcon = await screen.findByLabelText( /Species View/ ); + expect( speciesViewIcon ).toBeVisible( ); + const locationPermission = await screen.findByText( /Please allow Location Access/ ); + expect( locationPermission ).toBeVisible( ); + const closeButton = await screen.findByLabelText( /Close permission request screen/ ); + await actor.press( closeButton ); + const defaultGlobalLocation = await screen.findByText( /Worldwide/ ); + expect( defaultGlobalLocation ).toBeVisible( ); + const backButton = screen.queryByTestId( "Explore.BackButton" ); + expect( backButton ).toBeFalsy( ); + } ); + } ); + } ); +} ); diff --git a/tests/unit/components/BottomTabNavigator/__snapshots__/CustomTabBar.test.js.snap b/tests/unit/components/BottomTabNavigator/__snapshots__/CustomTabBar.test.js.snap index cd978a039..f601d0245 100644 --- a/tests/unit/components/BottomTabNavigator/__snapshots__/CustomTabBar.test.js.snap +++ b/tests/unit/components/BottomTabNavigator/__snapshots__/CustomTabBar.test.js.snap @@ -149,7 +149,7 @@ exports[`CustomTabBar should render correctly 1`] = `