Make MyObservationsSimple the standard UI across default/advanced mode (#2788)

* Update TypeScript

* Fix some tests with new default MyObservationsSimple

* Show upload toolbar on MyObs advanced

* Update tests for simple mode

* Fix deletions popping back up on MyObs

* Fix e2e test, which also means fixing our deletion process

* Fix useSyncObservations test

* Requested changes to better fit latest designs

* Add tests to check for hidden upload banner
This commit is contained in:
Amanda Bullington
2025-03-20 17:04:31 -07:00
committed by GitHub
parent 146886fbeb
commit 70ffa9112a
32 changed files with 384 additions and 1196 deletions

View File

@@ -8,6 +8,10 @@ import deleteObservation from "./sharedFlows/deleteObservation";
import signIn from "./sharedFlows/signIn";
import uploadObservation from "./sharedFlows/uploadObservation";
function delay( ms ) {
return new Promise( resolve => { setTimeout( resolve, ms ); } );
}
describe( "Signed in user", () => {
beforeAll( async ( ) => iNatE2eBeforeAll( device ) );
beforeEach( async ( ) => iNatE2eBeforeEach( device ) );
@@ -65,7 +69,7 @@ describe( "Signed in user", () => {
const username = await signIn( );
// Switch to list view as well
const listToggle = element( by.id( "MyObservationsToolbar.toggleListView" ) );
const listToggle = element( by.id( "SegmentedButton.list" ) );
await waitFor( listToggle ).toBeVisible( ).withTimeout( 10000 );
await listToggle.tap();
@@ -116,6 +120,11 @@ describe( "Signed in user", () => {
// point, and we can confirm deletion by testing for the absence of the
// list item for the observation we deleted.
await waitFor( element( by.text( /Upload 1 observation/ ) ) ).toBeVisible( ).withTimeout( 20_000 );
// the timing of syncing deletions seems to be different in the actual app versus these
// e2e tests, so deleting an observation here still shows the observation
// in the list unless this delay( ) is added
await delay( 3000 );
await expect( obsListItem ).toBeNotVisible( );
} );
} );

View File

@@ -24,7 +24,6 @@ const ITEMS = sortBy( [
{ title: "TaxonResult", component: "TaxonResultDemo" },
{ title: "ObsGridItem", component: "ObsGridItemDemo" },
{ title: "TaxonGridItem", component: "TaxonGridItemDemo" },
{ title: "Toolbar", component: "ToolbarDemo" },
{ title: "PivotCards", component: "PivotCardsDemo" }
], item => item.title );
ITEMS.push( { title: "Everything Else", component: "Misc" } );

View File

@@ -1,122 +0,0 @@
import Toolbar from "components/MyObservations/Toolbar";
import {
Heading1
} from "components/SharedComponents";
import {
ScrollView
} from "components/styledComponents";
import React from "react";
import colors from "styles/tailwindColors";
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};
/* eslint-disable i18next/no-literal-string */
/* eslint-disable react/no-unescaped-entities */
const ToolbarDemo = ( ) => (
<ScrollView>
<Heading1>Basic</Heading1>
<Toolbar
error=""
handleSyncButtonPress={noop}
layout="grid"
navToExplore={noop}
progress={0}
statusText=""
stopAllUploads={noop}
syncIconColor={colors.darkGray}
toggleLayout={noop}
/>
<Heading1>Needs Upload</Heading1>
<Toolbar
error=""
handleSyncButtonPress={noop}
layout="grid"
navToExplore={noop}
progress={0}
showsExclamation
showsExploreIcon
statusText="Upload 3 observations"
stopAllUploads={noop}
syncIconColor={colors.inatGreen}
toggleLayout={noop}
/>
<Heading1>Uploading</Heading1>
<Toolbar
error=""
handleSyncButtonPress={noop}
layout="grid"
navToExplore={noop}
progress={0.2}
rotating
showsCancelUploadButton
showsExploreIcon
statusText="doing some uploading"
stopAllUploads={noop}
syncIconColor={colors.inatGreen}
toggleLayout={noop}
/>
<Heading1>Uploading w/error</Heading1>
<Toolbar
error="something went terribly wrong"
handleSyncButtonPress={noop}
layout="grid"
navToExplore={noop}
progress={0.5}
rotating
showsCancelUploadButton
showsExploreIcon
statusText="doing some uploading"
stopAllUploads={noop}
syncIconColor={colors.inatGreen}
toggleLayout={noop}
/>
<Heading1>Uploading w/ long status</Heading1>
<Toolbar
error=""
handleSyncButtonPress={noop}
layout="grid"
navToExplore={noop}
progress={0.2}
rotating
showsCancelUploadButton
showsExploreIcon
statusText="doing some uploading that will be really great i promise"
stopAllUploads={noop}
syncIconColor={colors.inatGreen}
toggleLayout={noop}
/>
<Heading1>Finished w/error</Heading1>
<Toolbar
error="something went terribly wrong"
handleSyncButtonPress={noop}
layout="grid"
navToExplore={noop}
progress={1}
showsExploreIcon
showsExclamation
showsCheckmark
statusText="3 observations uploaded"
stopAllUploads={noop}
syncIconColor={colors.warningRed}
toggleLayout={noop}
/>
<Heading1>Error w/o status</Heading1>
<Toolbar
error="something went terribly, terribly, terribly wrong"
handleSyncButtonPress={noop}
layout="grid"
navToExplore={noop}
progress={1}
showsExploreIcon
showsExclamation
showsCheckmark
statusText=""
stopAllUploads={noop}
syncIconColor={colors.warningRed}
toggleLayout={noop}
/>
</ScrollView>
);
export default ToolbarDemo;

View File

@@ -12,7 +12,6 @@ import ObsListItemDemo from "./UiLibrary/ObsListItemDemo";
import PivotCardsDemo from "./UiLibrary/PivotCardsDemo";
import TaxonGridItemDemo from "./UiLibrary/TaxonGridItemDemo";
import TaxonResultDemo from "./UiLibrary/TaxonResultDemo";
import ToolbarDemo from "./UiLibrary/ToolbarDemo";
import Typography from "./UiLibrary/Typography";
const LIBRARY = {
@@ -26,7 +25,6 @@ const LIBRARY = {
PivotCardsDemo,
TaxonGridItemDemo,
TaxonResultDemo,
ToolbarDemo,
Typography
};

View File

@@ -1,108 +0,0 @@
// @flow
import MyObservationsHeader from "components/MyObservations/MyObservationsHeader";
import ObservationsFlashList from "components/ObservationsFlashList/ObservationsFlashList";
import OnboardingCarouselModal from "components/Onboarding/OnboardingCarouselModal";
import {
ScrollableWithStickyHeader,
ViewWrapper
} from "components/SharedComponents";
import type { Node } from "react";
import React from "react";
import { useOnboardingShown } from "sharedHelpers/installData.ts";
import Announcements from "./Announcements";
import LoginSheet from "./LoginSheet";
type Props = {
currentUser: Object,
handleIndividualUploadPress: Function,
handleSyncButtonPress: Function,
handlePullToRefresh: Function,
isConnected: boolean,
isFetchingNextPage: boolean,
layout: "list" | "grid",
listRef?: Object,
numUnuploadedObservations: number,
observations: Array<Object>,
onEndReached: Function,
onListLayout?: Function,
onScroll?: Function,
setShowLoginSheet: Function,
showLoginSheet: boolean,
showNoResults: boolean,
toggleLayout: Function
};
const MyObservations = ( {
currentUser,
handleIndividualUploadPress,
handleSyncButtonPress,
handlePullToRefresh,
isConnected,
isFetchingNextPage,
layout,
listRef,
numUnuploadedObservations,
observations,
onEndReached,
onListLayout,
onScroll,
setShowLoginSheet,
showLoginSheet,
showNoResults,
toggleLayout
}: Props ): Node => {
const [onboardingShown, setOnboardingShown] = useOnboardingShown( );
return (
<>
<ViewWrapper>
<OnboardingCarouselModal
showModal={!onboardingShown}
closeModal={() => setOnboardingShown( true )}
/>
<ScrollableWithStickyHeader
onScroll={onScroll}
renderHeader={setStickyAt => (
<MyObservationsHeader
currentUser={currentUser}
handleSyncButtonPress={handleSyncButtonPress}
hideToolbar={observations.length === 0}
layout={layout}
logInButtonNeutral={observations.length === 0}
numUnuploadedObservations={numUnuploadedObservations}
setHeightAboveToolbar={setStickyAt}
toggleLayout={toggleLayout}
/>
)}
renderScrollable={animatedScrollEvent => (
<ObservationsFlashList
dataCanBeFetched={!!currentUser}
data={observations.filter( o => o.isValid() )}
handlePullToRefresh={handlePullToRefresh}
handleIndividualUploadPress={handleIndividualUploadPress}
onScroll={animatedScrollEvent}
hideLoadingWheel={!isFetchingNextPage || !currentUser}
isConnected={isConnected}
obsListKey="MyObservations"
layout={layout}
onEndReached={onEndReached}
onLayout={onListLayout}
ref={listRef}
showObservationsEmptyScreen
showNoResults={showNoResults}
testID="MyObservationsAnimatedList"
renderHeader={(
<Announcements isConnected={isConnected} />
)}
/>
)}
/>
</ViewWrapper>
{showLoginSheet && <LoginSheet setShowLoginSheet={setShowLoginSheet} />}
</>
);
};
export default MyObservations;

View File

@@ -1,21 +1,23 @@
// @flow
import {
useNetInfo
} from "@react-native-community/netinfo";
import { useFocusEffect, useRoute } from "@react-navigation/native";
import { fetchSpeciesCounts } from "api/observations";
import { RealmContext } from "providers/contexts.ts";
import type { Node } from "react";
import React, {
useCallback,
useRef,
useEffect, useRef,
useState
} from "react";
import { Alert } from "react-native";
import Observation from "realmModels/Observation";
import Taxon from "realmModels/Taxon";
import type { RealmObservation, RealmTaxon } from "realmModels/types";
import { log } from "sharedHelpers/logger";
import {
useCurrentUser,
useInfiniteObservationsScroll,
useInfiniteScroll,
useLayoutPrefs,
useLocalObservations,
useNavigateToObsEdit,
@@ -28,20 +30,37 @@ import { isDebugMode } from "sharedHooks/useDebugMode";
import {
UPLOAD_PENDING
} from "stores/createUploadObservationsSlice.ts";
import useStore from "stores/useStore";
import useStore, { zustandStorage } from "stores/useStore";
import FullScreenActivityIndicator from "./FullScreenActivityIndicator";
import useSyncObservations from "./hooks/useSyncObservations";
import useUploadObservations from "./hooks/useUploadObservations";
import MyObservations from "./MyObservations";
import MyObservationsEmptySimple from "./MyObservationsEmptySimple";
import MyObservationsSimpleContainer from "./MyObservationsSimpleContainer";
import MyObservationsSimple, {
OBSERVATIONS_TAB,
TAXA_TAB
} from "./MyObservationsSimple";
const logger = log.extend( "MyObservationsContainer" );
const { useRealm } = RealmContext;
const MyObservationsContainer = ( ): Node => {
interface SpeciesCount {
count: number,
taxon: RealmTaxon
}
interface RouteParams {
justFinishedSignup?: boolean;
}
interface SyncOptions {
unuploadedObsMissingBasicsIDs?: string[];
skipUploads?: boolean;
skipSomeUploads?: string[];
}
const MyObservationsContainer = ( ): React.FC => {
const { loadTime } = usePerformance( {
screenName: "MyObservations"
} );
@@ -51,12 +70,12 @@ const MyObservationsContainer = ( ): Node => {
const { isDefaultMode, loggedInWhileInDefaultMode } = useLayoutPrefs();
const { t } = useTranslation( );
const realm = useRealm( );
const listRef = useRef( );
const listRef = useRef( null );
const navigateToObsEdit = useNavigateToObsEdit( );
// Get navigation params
const { params } = useRoute( );
const { justFinishedSignup } = params || {};
const { justFinishedSignup } = ( params as RouteParams ) || {};
const setStartUploadObservations = useStore( state => state.setStartUploadObservations );
const uploadQueue = useStore( state => state.uploadQueue );
@@ -79,7 +98,7 @@ const MyObservationsContainer = ( ): Node => {
const { isConnected } = useNetInfo( );
const currentUser = useCurrentUser( );
const currentUserId = currentUser?.id;
const canUpload = currentUser && isConnected;
const canUpload = !!currentUser && !!isConnected;
const { startUploadObservations } = useUploadObservations( canUpload );
const { syncManually } = useSyncObservations(
@@ -125,24 +144,28 @@ const MyObservationsContainer = ( ): Node => {
return currentUser;
}, [currentUser] );
const handleSyncButtonPress = useCallback( options => {
const handleSyncButtonPress = useCallback( ( options?: SyncOptions ) => {
const { unuploadedObsMissingBasicsIDs } = options || { };
if ( !confirmLoggedIn( ) ) { return; }
if ( !confirmInternetConnection( ) ) { return; }
startManualSync( );
syncManually( { skipSomeUploads: unuploadedObsMissingBasicsIDs } );
const syncOptions = isDefaultMode
? { skipSomeUploads: unuploadedObsMissingBasicsIDs }
: { };
syncManually( syncOptions );
}, [
startManualSync,
syncManually,
confirmInternetConnection,
confirmLoggedIn
confirmLoggedIn,
isDefaultMode
] );
const handleIndividualUploadPress = useCallback( uuid => {
const uploadExists = uploadQueue.includes( uuid );
if ( uploadExists ) return;
const observation = realm.objectForPrimaryKey( "Observation", uuid );
const observation = realm.objectForPrimaryKey<RealmObservation>( "Observation", uuid );
if ( isDefaultMode && observation.missingBasics( ) ) {
navigateToObsEdit( observation );
return;
@@ -206,11 +229,9 @@ const MyObservationsContainer = ( ): Node => {
myObsOffsetToRestore
] );
if ( !layout ) { return null; }
// API call fetching obs has completed but results are not yet stored in realm
// for display here
const showLoading = totalResultsRemote > 0 && observations.length === 0;
const showLoading = ( totalResultsRemote || 0 ) > 0 && observations.length === 0;
// show empty screen instead of loading wheel...
const showNoResults = !showLoading && (
@@ -225,7 +246,93 @@ const MyObservationsContainer = ( ): Node => {
// Keep track of the scroll offset so we can restore it when we mount
// this component again after returning from ObsEdit
const onScroll = scrollEvent => setMyObsOffset( scrollEvent.nativeEvent.contentOffset.y );
const onScroll = ( scrollEvent: {
nativeEvent: {
contentOffset: {
y: number
}
}
} ) => setMyObsOffset( scrollEvent.nativeEvent.contentOffset.y );
const numOfUserObservations = zustandStorage.getItem( "numOfUserObservations" );
const numOfUserSpecies = zustandStorage.getItem( "numOfUserSpecies" );
const [activeTab, setActiveTab] = useState( OBSERVATIONS_TAB );
let numTotalTaxaLocal: number | undefined;
const localObservedSpeciesCount: Array<SpeciesCount> = [];
if ( !currentUser ) {
// Calculate obs and leaf taxa counts from local observations
const distinctTaxonObs = realm.objects<RealmObservation>( "Observation" )
.filtered( "taxon != null DISTINCT(taxon.id)" );
const taxonIds: number[] = distinctTaxonObs
.map( o => o.taxon?.id || 0 )
.filter( Boolean );
const ancestorIds = distinctTaxonObs.map( o => {
// We're filtering b/c for taxa above species level, the taxon's own
// ID is included in ancestor ids for some reason (this is a bug...
// somewhere)
const taxonAncestorIds = (
o.taxon?.ancestor_ids || []
).filter( id => Number( id ) !== Number( o.taxon?.id ) );
return taxonAncestorIds;
} ).flat( );
const leafTaxonIds = taxonIds.filter( taxonId => !ancestorIds.includes( taxonId ) );
numTotalTaxaLocal = leafTaxonIds.length;
// Get leaf taxa if we're viewing the species tab
if ( activeTab === TAXA_TAB ) {
const localObs = realm.objects<RealmObservation>( "Observation" )
.filtered( "taxon.id IN $0", leafTaxonIds );
leafTaxonIds.forEach( id => {
const obs = localObs.filter( o => o.taxon.id === id );
localObservedSpeciesCount.push( { count: obs.length, taxon: obs[0].taxon } );
} );
}
}
const {
data: remoteObservedTaxaCounts,
isFetchingNextPage: isFetchingTaxa,
fetchNextPage: fetchMoreTaxa,
totalResults: numTotalTaxaRemote,
refetch: refetchTaxa
} = useInfiniteScroll(
"MyObsSimple-fetchSpeciesCounts",
fetchSpeciesCounts,
{
user_id: currentUser?.id,
fields: {
taxon: Taxon.LIMITED_TAXON_FIELDS
}
},
{
enabled: !!currentUser
}
);
const numTotalTaxa = typeof ( numTotalTaxaRemote ) === "number"
? numTotalTaxaRemote
: numTotalTaxaLocal;
const numTotalObservations = totalResultsRemote || totalResultsLocal;
useEffect( ( ) => {
// persist this number in zustand so a user can see their latest observations count
// even if they're offline
if ( numTotalObservations > numOfUserObservations ) {
zustandStorage.setItem( "numOfUserObservations", numTotalObservations );
}
}, [numTotalObservations, numOfUserObservations] );
useEffect( ( ) => {
// persist this number in zustand so a user can see their latest species count
// even if they're offline
if ( numTotalTaxa > numOfUserSpecies ) {
zustandStorage.setItem( "numOfUserSpecies", numTotalTaxa );
}
}, [numTotalTaxa, numOfUserSpecies] );
if ( !layout ) { return null; }
if ( observations.length === 0 ) {
return showNoResults
@@ -240,51 +347,38 @@ const MyObservationsContainer = ( ): Node => {
);
}
if ( isDefaultMode ) {
return (
<MyObservationsSimpleContainer
currentUser={currentUser}
isFetchingNextPage={isFetchingNextPage}
isConnected={isConnected}
handleIndividualUploadPress={handleIndividualUploadPress}
handleSyncButtonPress={handleSyncButtonPress}
handlePullToRefresh={handlePullToRefresh}
layout={layout}
listRef={listRef}
numUnuploadedObservations={numUnuploadedObservations}
numTotalObservations={totalResultsRemote || totalResultsLocal}
observations={observations}
onEndReached={fetchNextPage}
onListLayout={restoreScrollOffset}
onScroll={onScroll}
setShowLoginSheet={setShowLoginSheet}
showLoginSheet={showLoginSheet}
showNoResults={showNoResults}
toggleLayout={toggleLayout}
justFinishedSignup={justFinishedSignup}
loggedInWhileInDefaultMode={loggedInWhileInDefaultMode}
/>
);
}
const taxa = currentUser
? remoteObservedTaxaCounts
: localObservedSpeciesCount;
return (
<MyObservations
<MyObservationsSimple
activeTab={activeTab}
currentUser={currentUser}
isFetchingNextPage={isFetchingNextPage}
isConnected={isConnected}
fetchMoreTaxa={fetchMoreTaxa}
handleIndividualUploadPress={handleIndividualUploadPress}
handleSyncButtonPress={handleSyncButtonPress}
handlePullToRefresh={handlePullToRefresh}
handleSyncButtonPress={handleSyncButtonPress}
isConnected={isConnected}
isFetchingNextPage={isFetchingNextPage}
isFetchingTaxa={isFetchingTaxa}
justFinishedSignup={justFinishedSignup}
layout={layout}
listRef={listRef}
loggedInWhileInDefaultMode={loggedInWhileInDefaultMode}
numTotalObservations={numOfUserObservations}
numTotalTaxa={numOfUserSpecies}
numUnuploadedObservations={numUnuploadedObservations}
observations={observations}
onEndReached={fetchNextPage}
onListLayout={restoreScrollOffset}
onScroll={onScroll}
refetchTaxa={refetchTaxa}
setActiveTab={setActiveTab}
setShowLoginSheet={setShowLoginSheet}
showLoginSheet={showLoginSheet}
showNoResults={showNoResults}
taxa={taxa}
toggleLayout={toggleLayout}
/>
);

View File

@@ -1,146 +0,0 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import ToolbarContainer from "components/MyObservations/ToolbarContainer";
import {
Button,
Heading1,
INatIconButton,
Subheading1
} from "components/SharedComponents";
import { Pressable, View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import { Trans } from "react-i18next";
import { useTranslation } from "sharedHooks";
import colors from "styles/tailwindColors";
import Onboarding from "./Onboarding";
type Props = {
currentUser: ?Object,
handleSyncButtonPress: Function,
hideToolbar: boolean,
layout: string,
logInButtonNeutral: boolean,
numUnuploadedObservations: number,
setHeightAboveToolbar?: Function,
toggleLayout: Function
}
const MyObservationsHeader = ( {
currentUser,
handleSyncButtonPress,
hideToolbar,
layout,
logInButtonNeutral,
numUnuploadedObservations,
setHeightAboveToolbar,
toggleLayout
}: Props ): Node => {
const navigation = useNavigation( );
const { t } = useTranslation( );
const signedInContent = ( ) => (
<Pressable
className="flex flex-row items-center"
accessibilityRole="link"
accessibilityHint={t( "Navigates-to-user-profile" )}
onPress={() => {
navigation.push( "UserProfile", { userId: currentUser?.id } );
}}
>
<Trans
className="my-5"
i18nKey="Welcome-user"
parent={View}
values={{ userHandle: currentUser?.login }}
components={[
<Subheading1 />,
<Heading1 />
]}
/>
</Pressable>
);
const signedOutContent = ( ) => (
<View className="my-5 flex-col items-center">
<View className="flex-row items-center justify-center mb-5">
<INatIconButton
className="mr-5"
icon="inaturalist"
size={41}
color={colors.white}
backgroundColor={colors.inatGreen}
accessibilityLabel="iNaturalist"
mode="contained"
width={67}
height={67}
/>
{numUnuploadedObservations > 0
? (
<View className="shrink">
<Subheading1
maxFontSizeMultiplier={1.5}
testID="log-in-to-iNaturalist-text"
>
{t( "Log-in-to-contribute-and-sync" )}
</Subheading1>
<Heading1 maxFontSizeMultiplier={1.5}>
{ t( "X-observations", { count: numUnuploadedObservations } ) }
</Heading1>
</View>
)
: (
<Subheading1
maxFontSizeMultiplier={1.5}
className="shrink m-0"
testID="log-in-to-iNaturalist-text-no-observations"
>
{t( "Log-in-to-contribute-your-observations" )}
</Subheading1>
)}
</View>
<Button
onPress={( ) => navigation.navigate( "LoginStackNavigator" )}
accessibilityRole="link"
accessibilityLabel={t( "Log-in" )}
className="w-full"
text={t( "LOG-IN-TO-INATURALIST" )}
level={logInButtonNeutral
? "neutral"
: "focus"}
testID="log-in-to-iNaturalist-button"
/>
</View>
);
return (
<>
<View
className="px-5 bg-white w-full"
onLayout={event => {
const {
height
} = event.nativeEvent.layout;
if ( setHeightAboveToolbar ) {
setHeightAboveToolbar( height );
}
}}
>
{currentUser
? signedInContent( )
: signedOutContent( )}
<Onboarding />
</View>
{!hideToolbar && (
<ToolbarContainer
handleSyncButtonPress={handleSyncButtonPress}
layout={layout}
toggleLayout={toggleLayout}
/>
)}
</>
);
};
export default MyObservationsHeader;

View File

@@ -25,7 +25,7 @@ import type {
RealmUser
} from "realmModels/types";
import { accessibleTaxonName } from "sharedHelpers/taxon";
import { useGridLayout, useTranslation } from "sharedHooks";
import { useGridLayout, useLayoutPrefs, useTranslation } from "sharedHooks";
import colors from "styles/tailwindColors";
import Announcements from "./Announcements";
@@ -108,6 +108,7 @@ const MyObservationsSimple = ( {
loggedInWhileInDefaultMode = false,
refetchTaxa
}: Props ) => {
const { isDefaultMode } = useLayoutPrefs( );
const { t } = useTranslation( );
const navigation = useNavigation( );
const route = useRoute( );
@@ -201,7 +202,9 @@ const MyObservationsSimple = ( {
numUnuploadedObservations > 0 && numUnuploadedObsMissingBasics > 0
), [numUnuploadedObservations, numUnuploadedObsMissingBasics] );
const numUploadableObservations = numUnuploadedObservations - numUnuploadedObsMissingBasics;
const numUploadableObservations = isDefaultMode
? numUnuploadedObservations - numUnuploadedObsMissingBasics
: numUnuploadedObservations;
const renderTabComponent = ( { id } ) => (
<StatTab
@@ -276,7 +279,7 @@ const MyObservationsSimple = ( {
hideLoadingWheel
hideMetadata
hideObsUploadStatus={!currentUser}
hideObsStatus
hideObsStatus={isDefaultMode}
isFetchingNextPage={isFetchingNextPage}
isConnected={isConnected}
obsListKey="MyObservations"
@@ -327,19 +330,23 @@ const MyObservationsSimple = ( {
{ ( activeTab === TAXA_TAB && taxa.length === 0 ) && renderOfflineNotice( )}
</ViewWrapper>
{showLoginSheet && <LoginSheet setShowLoginSheet={setShowLoginSheet} />}
{/* These four cards should show only in default mode */}
<FirstObservationCard triggerCondition={numTotalObservations === 1} />
<SecondObservationCard triggerCondition={numTotalObservations === 2} />
<FiftyObservationCard
triggerCondition={
loggedInWhileInDefaultMode && !!currentUser && numTotalObservations >= 50
}
/>
<AccountCreationCard
triggerCondition={
justFinishedSignup && !!currentUser && numTotalObservations < 20
}
/>
{isDefaultMode && (
<>
{/* These four cards should show only in default mode */}
<FirstObservationCard triggerCondition={numTotalObservations === 1} />
<SecondObservationCard triggerCondition={numTotalObservations === 2} />
<FiftyObservationCard
triggerCondition={
loggedInWhileInDefaultMode && !!currentUser && numTotalObservations >= 50
}
/>
<AccountCreationCard
triggerCondition={
justFinishedSignup && !!currentUser && numTotalObservations < 20
}
/>
</>
)}
</>
);
};

View File

@@ -1,158 +0,0 @@
import { fetchSpeciesCounts } from "api/observations";
import { RealmContext } from "providers/contexts.ts";
import React, { useEffect, useState } from "react";
import Taxon from "realmModels/Taxon";
import type { RealmObservation, RealmTaxon } from "realmModels/types";
import { useInfiniteScroll } from "sharedHooks";
import { zustandStorage } from "stores/useStore";
import MyObservationsSimple, {
OBSERVATIONS_TAB,
Props,
TAXA_TAB
} from "./MyObservationsSimple";
const { useRealm } = RealmContext;
interface SpeciesCount {
count: number,
taxon: RealmTaxon
}
const MyObservationsSimpleContainer = ( {
currentUser,
handleIndividualUploadPress,
handleSyncButtonPress,
handlePullToRefresh,
isConnected,
isFetchingNextPage,
layout,
listRef,
numTotalObservations,
numUnuploadedObservations,
observations,
onEndReached,
onListLayout,
onScroll,
setShowLoginSheet,
showLoginSheet,
showNoResults,
toggleLayout,
justFinishedSignup,
loggedInWhileInDefaultMode
}: Props ) => {
const numOfUserObservations = zustandStorage.getItem( "numOfUserObservations" );
const numOfUserSpecies = zustandStorage.getItem( "numOfUserSpecies" );
const [activeTab, setActiveTab] = useState( OBSERVATIONS_TAB );
const realm = useRealm();
let numTotalTaxaLocal: number | undefined;
const localObservedSpeciesCount: Array<SpeciesCount> = [];
if ( !currentUser ) {
// Calculate obs and leaf taxa counts from local observations
const distinctTaxonObs = realm.objects<RealmObservation>( "Observation" )
.filtered( "taxon != null DISTINCT(taxon.id)" );
const taxonIds: number[] = distinctTaxonObs
.map( o => o.taxon?.id || 0 )
.filter( Boolean );
const ancestorIds = distinctTaxonObs.map( o => {
// We're filtering b/c for taxa above species level, the taxon's own
// ID is included in ancestor ids for some reason (this is a bug...
// somewhere)
const taxonAncestorIds = (
o.taxon?.ancestor_ids || []
).filter( id => Number( id ) !== Number( o.taxon?.id ) );
return taxonAncestorIds;
} ).flat( Infinity );
const leafTaxonIds = taxonIds.filter( taxonId => !ancestorIds.includes( taxonId ) );
numTotalTaxaLocal = leafTaxonIds.length;
// Get leaf taxa if we're viewing the species tab
if ( activeTab === TAXA_TAB ) {
// IDK how to placate TypeScript here. ~~~kueda 20250108
const localObs = realm.objects( "Observation" ).filtered( "taxon.id IN $0", leafTaxonIds );
leafTaxonIds.forEach( id => {
const obs = localObs.filter( o => o.taxon.id === id );
localObservedSpeciesCount.push( { count: obs.length, taxon: obs[0].taxon } );
} );
}
}
const {
data: remoteObservedTaxaCounts,
isFetchingNextPage: isFetchingTaxa,
fetchNextPage: fetchMoreTaxa,
totalResults: numTotalTaxaRemote,
refetch: refetchTaxa
} = useInfiniteScroll(
"MyObsSimple-fetchSpeciesCounts",
fetchSpeciesCounts,
{
user_id: currentUser?.id,
fields: {
taxon: Taxon.LIMITED_TAXON_FIELDS
}
},
{
enabled: !!currentUser
}
);
const numTotalTaxa = typeof ( numTotalTaxaRemote ) === "number"
? numTotalTaxaRemote
: numTotalTaxaLocal;
useEffect( ( ) => {
// persist this number in zustand so a user can see their latest observations count
// even if they're offline
if ( numTotalObservations > numOfUserObservations ) {
zustandStorage.setItem( "numOfUserObservations", numTotalObservations );
}
}, [numTotalObservations, numOfUserObservations] );
useEffect( ( ) => {
// persist this number in zustand so a user can see their latest species count
// even if they're offline
if ( numTotalTaxa > numOfUserSpecies ) {
zustandStorage.setItem( "numOfUserSpecies", numTotalTaxa );
}
}, [numTotalTaxa, numOfUserSpecies] );
return (
<MyObservationsSimple
currentUser={currentUser}
isFetchingNextPage={isFetchingNextPage}
isConnected={isConnected}
handleIndividualUploadPress={handleIndividualUploadPress}
handleSyncButtonPress={handleSyncButtonPress}
handlePullToRefresh={handlePullToRefresh}
layout={layout}
listRef={listRef}
numUnuploadedObservations={numUnuploadedObservations}
observations={observations}
onEndReached={onEndReached}
onListLayout={onListLayout}
onScroll={onScroll}
setShowLoginSheet={setShowLoginSheet}
showLoginSheet={showLoginSheet}
showNoResults={showNoResults}
toggleLayout={toggleLayout}
numTotalTaxa={numOfUserSpecies}
numTotalObservations={numOfUserObservations}
taxa={
currentUser
? remoteObservedTaxaCounts
: localObservedSpeciesCount
}
activeTab={activeTab}
setActiveTab={setActiveTab}
fetchMoreTaxa={fetchMoreTaxa}
isFetchingTaxa={isFetchingTaxa}
justFinishedSignup={justFinishedSignup}
loggedInWhileInDefaultMode={loggedInWhileInDefaultMode}
refetchTaxa={refetchTaxa}
/>
);
};
export default MyObservationsSimpleContainer;

View File

@@ -73,6 +73,7 @@ const MyObservationsSimpleHeader = ( {
<SimpleUploadBannerContainer
handleSyncButtonPress={handleSyncButtonPress}
numUploadableObservations={numUploadableObservations}
currentUser={currentUser}
/>
<View className="flex-row justify-between items-center px-5 py-1">
{currentUser

View File

@@ -1,27 +0,0 @@
// @flow
import { Body3 } from "components/SharedComponents";
import type { Node } from "react";
import React from "react";
import { useCurrentUser, useTranslation } from "sharedHooks";
const Onboarding = ( ): Node => {
const currentUser = useCurrentUser( );
const numObservations = currentUser?.observations_count || 0;
const { t } = useTranslation( );
const getOnboardingText = ( ) => {
if ( numObservations <= 10 ) {
return t( "As-you-upload-more-observations" );
}
if ( numObservations <= 50 ) {
return t( "Observations-you-upload-to-iNaturalist" );
}
return t( "You-can-search-observations-of-any-plant-or-animal" );
};
return numObservations <= 100 && numObservations > 0
? <Body3 className="pb-5">{getOnboardingText( ) }</Body3>
: null;
};
export default Onboarding;

View File

@@ -18,6 +18,7 @@ const SimpleErrorHeader = ( {
isConnected
}: Props ) => {
const { t } = useTranslation( );
return (
<>
<Announcements isConnected={isConnected} />

View File

@@ -25,12 +25,14 @@ const DELETION_STARTED_PROGRESS = 0.25;
type Props = {
numUploadableObservations: number,
handleSyncButtonPress: Function
handleSyncButtonPress: Function,
currentUser: Object
}
const SimpleUploadBannerContainer = ( {
handleSyncButtonPress,
numUploadableObservations
numUploadableObservations,
currentUser
}: Props ): Node => {
const numOfUserObservations = zustandStorage.getItem( "numOfUserObservations" );
const currentDeleteCount = useStore( state => state.currentDeleteCount );
@@ -176,7 +178,7 @@ const SimpleUploadBannerContainer = ( {
// hide when there are less than two observations to upload
// but make sure completed status is displayed when uploads are finished
if ( numOfUserObservations < 2 && progress === 0 ) {
if ( !currentUser && numOfUserObservations < 2 && progress === 0 ) {
return null;
}
return (

View File

@@ -1,173 +0,0 @@
// @flow
import classNames from "classnames";
import {
Body2,
Body4,
INatIcon,
INatIconButton,
RotatingINatIconButton,
UploadProgressBar
} from "components/SharedComponents";
import { Pressable, View } from "components/styledComponents";
import type { Node } from "react";
import React from "react";
import { useTranslation } from "sharedHooks";
import colors from "styles/tailwindColors";
type Props = {
error: ?string,
handleSyncButtonPress: Function,
layout: string,
navToExplore: Function,
progress: number,
rotating?: boolean,
showsCancelUploadButton?: boolean,
showsCheckmark?: boolean,
showsExclamation?: boolean,
showsExploreIcon?: boolean,
statusText: ?string,
stopAllUploads: Function,
syncIconColor: string,
toggleLayout: Function
}
const Toolbar = ( {
error,
handleSyncButtonPress,
layout,
navToExplore,
progress,
rotating = false,
showsCancelUploadButton = false,
showsCheckmark = false,
showsExclamation: showsExclamationProp = false,
showsExploreIcon = false,
statusText = "",
stopAllUploads,
syncIconColor,
toggleLayout
}: Props ): Node => {
const { t } = useTranslation( );
// The exclamation mark should *never* appear while rotating, no matter what
// the props say
let showsExclamation = showsExclamationProp;
if ( rotating ) showsExclamation = false;
return (
<View className={
classNames(
{ "border-b border-lightGray": layout !== "grid" }
)
}
>
<View className="flex-row mx-4">
{/* First col */}
{showsExploreIcon && (
<INatIconButton
icon="compass-rose-outline"
onPress={navToExplore}
accessibilityLabel={t( "See-all-your-observations-in-explore" )}
accessibilityHint={t( "Navigates-to-explore" )}
accessibilityRole="button"
size={30}
disabled={false}
// FWIW, IconButton has a little margin we can control and a
// little padding that we can't control, so the negative margin
// here is to ensure the visible icon is flush with the edge of
// the container
className="-ml-[7px]"
/>
)}
{/*
Center col. Initial width of 0 seems to be a hack that works to get
grow to work correctly with text content
*/}
<View
className={classNames(
"w-[0px] grow",
!showsExploreIcon && "-ml-[11px]"
)}
>
<View className="flex-row ml-1">
<View className="mr-1">
<RotatingINatIconButton
icon={
showsExclamation
? "sync-unsynced"
: "sync"
}
rotating={rotating}
onPress={handleSyncButtonPress}
color={syncIconColor}
disabled={rotating}
accessibilityLabel={t( "Sync-observations" )}
size={30}
testID="SyncButton"
/>
</View>
<View className="flex-row shrink">
<View className="shrink pb-1 justify-center">
{ statusText !== "" && (
<Pressable
onPress={handleSyncButtonPress}
className="flex-row items-center grow"
accessibilityRole="button"
disabled={rotating || showsCheckmark}
>
<Body2>
{statusText}
</Body2>
{showsCheckmark && (
<View className="ml-2">
<INatIcon name="checkmark" size={11} color={colors.inatGreen} />
</View>
)}
</Pressable>
)}
{error && (
statusText === ""
? <Body2 className="color-warningRed">{error}</Body2>
: <Body4 className="color-warningRed">{error}</Body4>
)}
</View>
{showsCancelUploadButton && (
<INatIconButton
icon="close"
size={11}
accessibilityLabel={t( "Stop-upload" )}
onPress={stopAllUploads}
/>
)}
</View>
</View>
</View>
{/* Last col */}
<INatIconButton
icon={layout === "grid"
? "listview"
: "gridview"}
size={30}
disabled={false}
accessibilityLabel={layout === "grid"
? t( "List-layout" )
: t( "Grid-layout" )}
testID={
layout === "list"
? "MyObservationsToolbar.toggleGridView"
: "MyObservationsToolbar.toggleListView"
}
onPress={toggleLayout}
// Negative margin here is similar to above: trying to get the icon
// flush with the container. ml-auto is a bit of a hack to pull
// this button all the way to the end.
className="-mr-[7px]"
/>
</View>
<UploadProgressBar progress={progress} />
</View>
);
};
export default Toolbar;

View File

@@ -1,219 +0,0 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import type { Node } from "react";
import React, { useCallback, useMemo } from "react";
import { Dimensions, PixelRatio } from "react-native";
import {
useCurrentUser,
useTranslation
} from "sharedHooks";
import {
AUTOMATIC_SYNC_IN_PROGRESS,
MANUAL_SYNC_IN_PROGRESS
} from "stores/createSyncObservationsSlice.ts";
import {
UPLOAD_COMPLETE,
UPLOAD_IN_PROGRESS,
UPLOAD_PENDING
} from "stores/createUploadObservationsSlice.ts";
import useStore from "stores/useStore";
import colors from "styles/tailwindColors";
import Toolbar from "./Toolbar";
const screenWidth = Dimensions.get( "window" ).width * PixelRatio.get( );
const DELETION_STARTED_PROGRESS = 0.25;
type Props = {
handleSyncButtonPress: Function,
layout: string,
toggleLayout: Function
}
const ToolbarContainer = ( {
handleSyncButtonPress,
layout,
toggleLayout
}: Props ): Node => {
const setExploreView = useStore( state => state.setExploreView );
const currentUser = useCurrentUser( );
const navigation = useNavigation( );
const currentDeleteCount = useStore( state => state.currentDeleteCount );
const deleteError = useStore( state => state.deleteError );
const uploadMultiError = useStore( state => state.multiError );
const uploadErrorsByUuid = useStore( state => state.errorsByUuid );
const initialNumObservationsInQueue = useStore( state => state.initialNumObservationsInQueue );
const numUnuploadedObservations = useStore( state => state.numUnuploadedObservations );
const totalToolbarProgress = useStore( state => state.totalToolbarProgress );
const uploadStatus = useStore( state => state.uploadStatus );
const syncingStatus = useStore( state => state.syncingStatus );
const initialNumDeletionsInQueue = useStore( state => state.initialNumDeletionsInQueue );
const stopAllUploads = useStore( state => state.stopAllUploads );
const numUploadsAttempted = useStore( state => state.numUploadsAttempted );
// Note that initialNumObservationsInQueue is the number of obs being uploaded in
// the current upload session, so it might be 1 if a single obs is
// being uploaded even though 5 obs need upload
const translationParams = useMemo( ( ) => ( {
total: initialNumObservationsInQueue,
currentUploadCount: Math.min( numUploadsAttempted, initialNumObservationsInQueue )
} ), [
initialNumObservationsInQueue,
numUploadsAttempted
] );
const navToExplore = useCallback(
( ) => {
setExploreView( "observations" );
navigation.navigate( "Explore", {
user: currentUser,
worldwide: true
} );
},
[navigation, currentUser, setExploreView]
);
const { t } = useTranslation( );
const deletionsComplete = initialNumDeletionsInQueue === currentDeleteCount;
const deletionsInProgress = initialNumDeletionsInQueue > 0 && !deletionsComplete;
const automaticSyncInProgress = syncingStatus === AUTOMATIC_SYNC_IN_PROGRESS;
const manualSyncInProgress = syncingStatus === MANUAL_SYNC_IN_PROGRESS;
const pendingUpload = uploadStatus === UPLOAD_PENDING && numUnuploadedObservations > 0;
const uploadInProgress = uploadStatus === UPLOAD_IN_PROGRESS && numUploadsAttempted > 0;
const uploadsComplete = uploadStatus === UPLOAD_COMPLETE && initialNumObservationsInQueue > 0;
const totalUploadErrors = Object.keys( uploadErrorsByUuid ).length;
const setDeletionsProgress = ( ) => {
// TODO: we should emit deletions progress like we do for uploads for an accurate progress
// right now, a user can only delete a single local upload at a time from ObsEdit
// so we don't need a more robust count here (20240607)
if ( initialNumDeletionsInQueue === 0 ) {
return 0;
}
if ( !deletionsComplete ) {
return currentDeleteCount * DELETION_STARTED_PROGRESS;
}
return 1;
};
const deletionsProgress = setDeletionsProgress( );
const showFinalUploadError = ( totalUploadErrors > 0 && uploadsComplete )
|| ( totalUploadErrors > 0 && ( numUploadsAttempted === initialNumObservationsInQueue ) );
const rotating = manualSyncInProgress || uploadInProgress || deletionsInProgress;
const showsCheckmark = ( uploadsComplete && !uploadMultiError )
|| ( deletionsComplete && !deleteError && initialNumDeletionsInQueue > 0 );
const showsExclamation = pendingUpload || showFinalUploadError;
const getStatusText = useCallback( ( ) => {
if ( manualSyncInProgress ) { return t( "Syncing" ); }
const deletionParams = {
total: initialNumDeletionsInQueue,
currentDeleteCount
};
if ( initialNumDeletionsInQueue > 0 ) {
if ( deletionsComplete ) {
return t( "X-observations-deleted", { count: initialNumDeletionsInQueue } );
}
// iPhone 4 pixel width
return screenWidth <= 640
? t( "Deleting-x-of-y--observations", deletionParams )
: t( "Deleting-x-of-y-observations-2", deletionParams );
}
if ( pendingUpload ) {
return t( "Upload-x-observations", { count: numUnuploadedObservations } );
}
if ( uploadInProgress ) {
// iPhone 4 pixel width
return screenWidth <= 640
? t( "Uploading-x-of-y", translationParams )
: t( "Uploading-x-of-y-observations", translationParams );
}
if ( uploadsComplete ) {
return t( "X-observations-uploaded", { count: numUploadsAttempted } );
}
return "";
}, [
currentDeleteCount,
deletionsComplete,
initialNumDeletionsInQueue,
numUploadsAttempted,
numUnuploadedObservations,
pendingUpload,
manualSyncInProgress,
t,
translationParams,
uploadInProgress,
uploadsComplete
] );
const errorText = useMemo( ( ) => {
if ( automaticSyncInProgress ) {
return null;
}
let error;
if ( deleteError ) {
error = deleteError;
} else if ( totalUploadErrors > 0 ) {
error = t( "x-uploads-failed", { count: totalUploadErrors } );
} else {
error = uploadMultiError;
}
return error;
}, [
deleteError,
automaticSyncInProgress,
t,
totalUploadErrors,
uploadMultiError
] );
const getSyncIconColor = useCallback( ( ) => {
if ( showFinalUploadError ) {
return colors.warningRed;
}
if ( pendingUpload || uploadInProgress ) {
return colors.inatGreen;
}
return colors.darkGray;
}, [
showFinalUploadError,
pendingUpload,
uploadInProgress
] );
const statusText = getStatusText( );
const syncIconColor = getSyncIconColor( );
return (
<Toolbar
error={errorText}
handleSyncButtonPress={handleSyncButtonPress}
layout={layout}
navToExplore={navToExplore}
progress={totalToolbarProgress || deletionsProgress}
rotating={rotating}
showsCancelUploadButton={uploadInProgress}
showsCheckmark={showsCheckmark}
showsExclamation={showsExclamation}
showsExploreIcon={currentUser}
statusText={statusText}
stopAllUploads={stopAllUploads}
syncIconColor={syncIconColor}
toggleLayout={toggleLayout}
/>
);
};
export default ToolbarContainer;

View File

@@ -64,12 +64,24 @@ const useSyncObservations = (
deleteQueue.forEach( async ( uuid: string, i: number ) => {
setCurrentDeletionUuid( uuid );
const observation = realm.objectForPrimaryKey( "Observation", uuid );
// Mark as pending deletion first instead of immediately deleting
Observation.markPendingDeletion( realm, uuid );
const hasBeenSyncedRemotely = observation?._synced_at;
if ( !hasBeenSyncedRemotely ) {
Observation.deleteLocalObservation( realm, uuid );
} else {
handleRemoteDeletion.mutate( { uuid } );
try {
await handleRemoteDeletion.mutateAsync( { uuid } );
} catch ( error ) {
// In case of failure, clear the pending deletion flag after some time
// to allow retrying later
setTimeout( ( ) => {
Observation.clearPendingDeletion( realm, uuid );
}, 60000 );
}
}
if ( i > 0 ) {

View File

@@ -129,8 +129,6 @@ Apply-filters = Apply filters
April = April
Are-you-an-educator = Are you an educator wanting to use iNaturalist with your students?
Are-you-sure-you-want-to-log-out = Are you sure you want to log out of your iNaturalist account? All observations that havent been uploaded to iNaturalist will be deleted.
# Onboarding text on MyObservations: 0-10 observations
As-you-upload-more-observations = As you upload more observations, others in our community may be able to help you identify them!
attribution-cc-by = some rights reserved (CC BY)
attribution-cc-by-nc = some rights reserved (CC BY-NC)
attribution-cc-by-nc-nd = some rights reserved (CC BY-NC-ND)
@@ -551,8 +549,6 @@ Google-Play-Services-Not-Installed = Google Play Services Not Installed
GRANT-PERMISSION = GRANT PERMISSION
# Title of a screen asking for permission
Grant-Permission-title = Grant Permission
# Label for button to switch to a grid layout of observations
Grid-layout = Grid layout
Group-Photos = Group Photos
# Onboarding for users learning to group photos in the camera roll
Group-photos-onboarding = Group photos into observations make sure there is only one species per observation
@@ -692,8 +688,6 @@ LEAVE-PROJECT = LEAVE PROJECT
LEAVE-PROJECT--question = LEAVE PROJECT?
LEAVE-US-A-REVIEW = LEAVE US A REVIEW!
Lets-reset-your-password = Lets reset your password.
# Label for button to switch to a list layout of observations
List-layout = List layout
Loading-iNaturalists-AI-Camera = Loading iNaturalist's AI Camera
Loads-content-that-requires-an-Internet-connection = Loads content that requires an Internet connection
LOCATION = LOCATION
@@ -701,10 +695,6 @@ Location = Location
Location-accuracy-is-too-imprecise = Location accuracy is too imprecise to help identifiers. Please zoom in.
LOCATION-TOO-IMPRECISE = LOCATION TOO IMPRECISE
LOG-IN = LOG IN
# Second person imperative label to go to log in screen
Log-in = Log in
Log-in-to-contribute-and-sync = Log in to contribute & sync
Log-in-to-contribute-your-observations = Log in to contribute your observations to science!
LOG-IN-TO-INATURALIST = LOG IN TO INATURALIST
Log-in-to-iNaturalist = Log in to iNaturalist
LOG-OUT = LOG OUT
@@ -863,8 +853,6 @@ OBSERVATIONS-WITHOUT-NUMBER =
[one] OBSERVATION
*[other] OBSERVATIONS
}
# Onboarding text on MyObservations: Onboarding text on MyObservations: 11-50 observations
Observations-you-upload-to-iNaturalist = Observations you upload to iNaturalist can be used by scientists and researchers worldwide.
# Title of screen asking for permission to access the camera
Observe-and-identify-organisms-in-real-time-with-your-camera = Observe and identify organisms in real time with your camera
# Text for a button prompting the user to grant access to the camera
@@ -1126,8 +1114,6 @@ Search-suggestions-with-location = Search suggestions with location
Search-suggestions-without-location = Search suggestions without location
SEARCH-TAXA = SEARCH TAXA
SEARCH-USERS = SEARCH USERS
# Accessibility label for Explore button on MyObservations toolbar
See-all-your-observations-in-explore = See all your observations 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
@@ -1355,8 +1341,6 @@ View-suggestions = View suggestions
Watch-your-notifications-for-identifications = Watch your notifications for identifications!
We-are-not-confident-enough-to-make-a-top-ID-suggestion = Were not confident enough to make a top ID suggestion, but here are some other suggestions:
Welcome-back = Welcome back!
# Welcome user back to app
Welcome-user = <0>Welcome back,</0><1>{ $userHandle }</1>
Weve-made-some-updates = We've made some updates, so we recommend taking a look at your settings. You can always update these later.
WHAT-IS-INATURALIST = WHAT IS INATURALIST?
Whats-more-by-recording = What's more, by recording and sharing your observations, you'll create research-quality data for scientists working to better understand and protect nature. So if you like recording your findings from the outdoors, or if you just like learning about life, join us!
@@ -1414,12 +1398,6 @@ X-Observations =
[one] 1 Observation
*[other] { $count } Observations
}
# Shows number of observations in a variety of contexts
X-observations =
{ $count ->
[one] 1 observation
*[other] { $count } observations
}
# Label for a count of observations that appears above this text
X-OBSERVATIONS--below-number =
{ $count ->
@@ -1527,8 +1505,6 @@ You-can-also-check-out-merchandise = You can also check out merchandise for iNat
You-can-click-join-on-the-project-page = You can click “join” on the project page.
You-can-find-answers-on-our-help-page = You can find answers on our help page.
You-can-only-add-20-photos-per-observation = You can only add 20 photos per observation
# Onboarding text on MyObservations: Onboarding text on MyObservations: 51-100 observations
You-can-search-observations-of-any-plant-or-animal = You can search observations of any plant or animal anywhere in the world with Explore!
You-can-still-share-the-file = You can still share the file with another app. If you can email it, please send it to { $email }
You-can-upload-this-observation-to-our-community = You can upload this observation to our community to get an identification from a real person, and help our AI improve its identifications in the future
You-changed-filters-will-be-discarded = You changed filters, but they were not applied to your explore search results.

View File

@@ -62,7 +62,6 @@
"April": "April",
"Are-you-an-educator": "Are you an educator wanting to use iNaturalist with your students?",
"Are-you-sure-you-want-to-log-out": "Are you sure you want to log out of your iNaturalist account? All observations that havent been uploaded to iNaturalist will be deleted.",
"As-you-upload-more-observations": "As you upload more observations, others in our community may be able to help you identify them!",
"attribution-cc-by": "some rights reserved (CC BY)",
"attribution-cc-by-nc": "some rights reserved (CC BY-NC)",
"attribution-cc-by-nc-nd": "some rights reserved (CC BY-NC-ND)",
@@ -310,7 +309,6 @@
"Google-Play-Services-Not-Installed": "Google Play Services Not Installed",
"GRANT-PERMISSION": "GRANT PERMISSION",
"Grant-Permission-title": "Grant Permission",
"Grid-layout": "Grid layout",
"Group-Photos": "Group Photos",
"Group-photos-onboarding": "Group photos into observations make sure there is only one species per observation",
"Grow-your-collection": "Grow your collection",
@@ -403,7 +401,6 @@
"LEAVE-PROJECT--question": "LEAVE PROJECT?",
"LEAVE-US-A-REVIEW": "LEAVE US A REVIEW!",
"Lets-reset-your-password": "Lets reset your password.",
"List-layout": "List layout",
"Loading-iNaturalists-AI-Camera": "Loading iNaturalist's AI Camera",
"Loads-content-that-requires-an-Internet-connection": "Loads content that requires an Internet connection",
"LOCATION": "LOCATION",
@@ -411,9 +408,6 @@
"Location-accuracy-is-too-imprecise": "Location accuracy is too imprecise to help identifiers. Please zoom in.",
"LOCATION-TOO-IMPRECISE": "LOCATION TOO IMPRECISE",
"LOG-IN": "LOG IN",
"Log-in": "Log in",
"Log-in-to-contribute-and-sync": "Log in to contribute & sync",
"Log-in-to-contribute-your-observations": "Log in to contribute your observations to science!",
"LOG-IN-TO-INATURALIST": "LOG IN TO INATURALIST",
"Log-in-to-iNaturalist": "Log in to iNaturalist",
"LOG-OUT": "LOG OUT",
@@ -511,7 +505,6 @@
"Observations-on-iNat-are-cited": "Observations on iNaturalist are cited in scientific papers, have led to rediscoveries, and help scientists understand life on our planet",
"Observations-View": "Observations View",
"OBSERVATIONS-WITHOUT-NUMBER": "{ $count ->\n [one] OBSERVATION\n *[other] OBSERVATIONS\n}",
"Observations-you-upload-to-iNaturalist": "Observations you upload to iNaturalist can be used by scientists and researchers worldwide.",
"Observe-and-identify-organisms-in-real-time-with-your-camera": "Observe and identify organisms in real time with your camera",
"OBSERVE-ORGANISMS": "OBSERVE ORGANISMS",
"OBSERVED-AT--label": "OBSERVED AT",
@@ -712,7 +705,6 @@
"Search-suggestions-without-location": "Search suggestions without location",
"SEARCH-TAXA": "SEARCH TAXA",
"SEARCH-USERS": "SEARCH USERS",
"See-all-your-observations-in-explore": "See all your observations in explore",
"See-observations-by-this-user-in-Explore": "See observations by this user in Explore",
"See-observations-of-this-taxon-in-explore": "See observations of this taxon in explore",
"See-project-members": "See project members",
@@ -875,7 +867,6 @@
"Watch-your-notifications-for-identifications": "Watch your notifications for identifications!",
"We-are-not-confident-enough-to-make-a-top-ID-suggestion": "Were not confident enough to make a top ID suggestion, but here are some other suggestions:",
"Welcome-back": "Welcome back!",
"Welcome-user": "<0>Welcome back,</0><1>{ $userHandle }</1>",
"Weve-made-some-updates": "We've made some updates, so we recommend taking a look at your settings. You can always update these later.",
"WHAT-IS-INATURALIST": "WHAT IS INATURALIST?",
"Whats-more-by-recording": "What's more, by recording and sharing your observations, you'll create research-quality data for scientists working to better understand and protect nature. So if you like recording your findings from the outdoors, or if you just like learning about life, join us!",
@@ -899,7 +890,6 @@
"X-Identifiers": "{ $count ->\n [one] { $count } Identifier\n *[other] { $count } Identifiers\n}",
"X-MEMBERS": "{ $count } MEMBERS",
"X-Observations": "{ $count ->\n [one] 1 Observation\n *[other] { $count } Observations\n}",
"X-observations": "{ $count ->\n [one] 1 observation\n *[other] { $count } observations\n}",
"X-OBSERVATIONS--below-number": "{ $count ->\n [one] OBSERVATION\n *[other] OBSERVATIONS\n}",
"X-observations-deleted": "{ $count ->\n [one] 1 observation deleted\n *[other] { $count } observations deleted\n}",
"X-observations-uploaded": "{ $count ->\n [one] 1 observation uploaded\n *[other] { $count } observations uploaded\n}",
@@ -926,7 +916,6 @@
"You-can-click-join-on-the-project-page": "You can click “join” on the project page.",
"You-can-find-answers-on-our-help-page": "You can find answers on our help page.",
"You-can-only-add-20-photos-per-observation": "You can only add 20 photos per observation",
"You-can-search-observations-of-any-plant-or-animal": "You can search observations of any plant or animal anywhere in the world with Explore!",
"You-can-still-share-the-file": "You can still share the file with another app. If you can email it, please send it to { $email }",
"You-can-upload-this-observation-to-our-community": "You can upload this observation to our community to get an identification from a real person, and help our AI improve its identifications in the future",
"You-changed-filters-will-be-discarded": "You changed filters, but they were not applied to your explore search results.",

View File

@@ -129,8 +129,6 @@ Apply-filters = Apply filters
April = April
Are-you-an-educator = Are you an educator wanting to use iNaturalist with your students?
Are-you-sure-you-want-to-log-out = Are you sure you want to log out of your iNaturalist account? All observations that havent been uploaded to iNaturalist will be deleted.
# Onboarding text on MyObservations: 0-10 observations
As-you-upload-more-observations = As you upload more observations, others in our community may be able to help you identify them!
attribution-cc-by = some rights reserved (CC BY)
attribution-cc-by-nc = some rights reserved (CC BY-NC)
attribution-cc-by-nc-nd = some rights reserved (CC BY-NC-ND)
@@ -551,8 +549,6 @@ Google-Play-Services-Not-Installed = Google Play Services Not Installed
GRANT-PERMISSION = GRANT PERMISSION
# Title of a screen asking for permission
Grant-Permission-title = Grant Permission
# Label for button to switch to a grid layout of observations
Grid-layout = Grid layout
Group-Photos = Group Photos
# Onboarding for users learning to group photos in the camera roll
Group-photos-onboarding = Group photos into observations make sure there is only one species per observation
@@ -692,8 +688,6 @@ LEAVE-PROJECT = LEAVE PROJECT
LEAVE-PROJECT--question = LEAVE PROJECT?
LEAVE-US-A-REVIEW = LEAVE US A REVIEW!
Lets-reset-your-password = Lets reset your password.
# Label for button to switch to a list layout of observations
List-layout = List layout
Loading-iNaturalists-AI-Camera = Loading iNaturalist's AI Camera
Loads-content-that-requires-an-Internet-connection = Loads content that requires an Internet connection
LOCATION = LOCATION
@@ -701,10 +695,6 @@ Location = Location
Location-accuracy-is-too-imprecise = Location accuracy is too imprecise to help identifiers. Please zoom in.
LOCATION-TOO-IMPRECISE = LOCATION TOO IMPRECISE
LOG-IN = LOG IN
# Second person imperative label to go to log in screen
Log-in = Log in
Log-in-to-contribute-and-sync = Log in to contribute & sync
Log-in-to-contribute-your-observations = Log in to contribute your observations to science!
LOG-IN-TO-INATURALIST = LOG IN TO INATURALIST
Log-in-to-iNaturalist = Log in to iNaturalist
LOG-OUT = LOG OUT
@@ -863,8 +853,6 @@ OBSERVATIONS-WITHOUT-NUMBER =
[one] OBSERVATION
*[other] OBSERVATIONS
}
# Onboarding text on MyObservations: Onboarding text on MyObservations: 11-50 observations
Observations-you-upload-to-iNaturalist = Observations you upload to iNaturalist can be used by scientists and researchers worldwide.
# Title of screen asking for permission to access the camera
Observe-and-identify-organisms-in-real-time-with-your-camera = Observe and identify organisms in real time with your camera
# Text for a button prompting the user to grant access to the camera
@@ -1126,8 +1114,6 @@ Search-suggestions-with-location = Search suggestions with location
Search-suggestions-without-location = Search suggestions without location
SEARCH-TAXA = SEARCH TAXA
SEARCH-USERS = SEARCH USERS
# Accessibility label for Explore button on MyObservations toolbar
See-all-your-observations-in-explore = See all your observations 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
@@ -1355,8 +1341,6 @@ View-suggestions = View suggestions
Watch-your-notifications-for-identifications = Watch your notifications for identifications!
We-are-not-confident-enough-to-make-a-top-ID-suggestion = Were not confident enough to make a top ID suggestion, but here are some other suggestions:
Welcome-back = Welcome back!
# Welcome user back to app
Welcome-user = <0>Welcome back,</0><1>{ $userHandle }</1>
Weve-made-some-updates = We've made some updates, so we recommend taking a look at your settings. You can always update these later.
WHAT-IS-INATURALIST = WHAT IS INATURALIST?
Whats-more-by-recording = What's more, by recording and sharing your observations, you'll create research-quality data for scientists working to better understand and protect nature. So if you like recording your findings from the outdoors, or if you just like learning about life, join us!
@@ -1414,12 +1398,6 @@ X-Observations =
[one] 1 Observation
*[other] { $count } Observations
}
# Shows number of observations in a variety of contexts
X-observations =
{ $count ->
[one] 1 observation
*[other] { $count } observations
}
# Label for a count of observations that appears above this text
X-OBSERVATIONS--below-number =
{ $count ->
@@ -1527,8 +1505,6 @@ You-can-also-check-out-merchandise = You can also check out merchandise for iNat
You-can-click-join-on-the-project-page = You can click “join” on the project page.
You-can-find-answers-on-our-help-page = You can find answers on our help page.
You-can-only-add-20-photos-per-observation = You can only add 20 photos per observation
# Onboarding text on MyObservations: Onboarding text on MyObservations: 51-100 observations
You-can-search-observations-of-any-plant-or-animal = You can search observations of any plant or animal anywhere in the world with Explore!
You-can-still-share-the-file = You can still share the file with another app. If you can email it, please send it to { $email }
You-can-upload-this-observation-to-our-community = You can upload this observation to our community to get an identification from a real person, and help our AI improve its identifications in the future
You-changed-filters-will-be-discarded = You changed filters, but they were not applied to your explore search results.

View File

@@ -16,7 +16,7 @@ import ExploreProjectSearch from "components/Explore/SearchScreens/ExploreProjec
import ExploreTaxonSearch from "components/Explore/SearchScreens/ExploreTaxonSearch";
import ExploreUserSearch from "components/Explore/SearchScreens/ExploreUserSearch";
import Help from "components/Help/Help.tsx";
import MyObservationsContainer from "components/MyObservations/MyObservationsContainer";
import MyObservationsContainer from "components/MyObservations/MyObservationsContainer.tsx";
import Notifications from "components/Notifications/Notifications.tsx";
import DQAContainer from "components/ObsDetails/DQAContainer";
import ObsDetailsContainer from "components/ObsDetails/ObsDetailsContainer";

View File

@@ -460,10 +460,29 @@ class Observation extends Realm.Object {
}
};
static markPendingDeletion( realm, uuidToDelete ) {
const observation = realm.objectForPrimaryKey( "Observation", uuidToDelete );
if ( observation ) {
safeRealmWrite( realm, ( ) => {
observation._pending_deletion = true;
} );
}
}
static clearPendingDeletion( realm, uuidToDelete ) {
const observation = realm.objectForPrimaryKey( "Observation", uuidToDelete );
if ( observation ) {
safeRealmWrite( realm, ( ) => {
observation._pending_deletion = false;
} );
}
}
static schema = {
name: "Observation",
primaryKey: "uuid",
properties: {
_pending_deletion: "bool?",
// datetime the observation was created on the device
_created_at: "date?",
// datetime the observation was requested to be deleted

View File

@@ -33,7 +33,7 @@ export default {
User,
Vote
],
schemaVersion: 64,
schemaVersion: 66,
path: `${RNFS.DocumentDirectoryPath}/db.realm`,
// https://github.com/realm/realm-js/pull/6076 embedded constraints
migrationOptions: {

View File

@@ -31,7 +31,9 @@ const useLocalObservations = ( ): Object => {
[["needs_sync", true], ["_created_at", true]]
);
const obsNotFlaggedForDeletion = sortedCollection.filtered( "_deleted_at == nil" );
// eslint-disable-next-line max-len
const deletionFilters = "_deleted_at == nil OR _pending_deletion == false OR _pending_deletion == nil";
const obsNotFlaggedForDeletion = sortedCollection.filtered( deletionFilters );
stagedObservationList.current = [...obsNotFlaggedForDeletion];
const unsynced = Observation.filterUnsyncedObservations( realm );

View File

@@ -3,7 +3,7 @@
import { fireEvent, screen, waitFor } from "@testing-library/react-native";
import { MS_BEFORE_TOOLBAR_RESET } from "components/MyObservations/hooks/useUploadObservations.ts";
import MyObservationsContainer from "components/MyObservations/MyObservationsContainer";
import MyObservationsContainer from "components/MyObservations/MyObservationsContainer.tsx";
import i18next from "i18next";
import inatjs from "inaturalistjs";
import { flatten } from "lodash";
@@ -324,7 +324,7 @@ describe( "MyObservations", ( ) => {
// expect( inatjs.observations.updates ).not.toHaveBeenCalled();
inatjs.observations.updates.mockClear( );
renderAppWithComponent( <MyObservationsContainer /> );
expect( await screen.findByText( /Welcome back/ ) ).toBeTruthy();
expect( await screen.findByText( /OBSERVATIONS/ ) ).toBeTruthy();
await waitFor( ( ) => {
expect( inatjs.observations.updates ).toHaveBeenCalled( );
} );
@@ -345,7 +345,7 @@ describe( "MyObservations", ( ) => {
const realm = global.mockRealms[__filename];
expect( realm.objects( "Observation" ).length ).toBeGreaterThan( 0 );
renderAppWithComponent( <MyObservationsContainer /> );
const button = await screen.findByTestId( "MyObservationsToolbar.toggleListView" );
const button = await screen.findByTestId( "SegmentedButton.list" );
fireEvent.press( button );
// Awaiting the first observation because using await in the forEach errors out
const firstObs = mockSyncedObservations[0];
@@ -355,7 +355,13 @@ describe( "MyObservations", ( ) => {
} );
} );
it( "displays observation status in list view", async () => {
it( "displays observation status in list view in advanced mode", async () => {
useStore.setState( {
layout: {
isDefaultMode: false
},
isAdvancedUser: true
} );
const realm = global.mockRealms[__filename];
expect( realm.objects( "Observation" ).length ).toBeGreaterThan( 0 );
renderAppWithComponent( <MyObservationsContainer /> );

View File

@@ -1,5 +1,5 @@
import { screen, waitFor } from "@testing-library/react-native";
import MyObservationsContainer from "components/MyObservations/MyObservationsContainer";
import MyObservationsContainer from "components/MyObservations/MyObservationsContainer.tsx";
// import inatjs from "inaturalistjs";
import React from "react";
import useStore from "stores/useStore";

View File

@@ -2,7 +2,7 @@
// remote data retrieval and local data persistence
import { screen, waitFor } from "@testing-library/react-native";
import MyObservationsContainer from "components/MyObservations/MyObservationsContainer";
import MyObservationsContainer from "components/MyObservations/MyObservationsContainer.tsx";
import React from "react";
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
import useStore from "stores/useStore";

View File

@@ -100,7 +100,7 @@ const actor = userEvent.setup( );
async function navigateToObsDetails( ) {
await waitFor( ( ) => {
global.timeTravel( );
expect( screen.getByText( /Welcome back/ ) ).toBeVisible( );
expect( screen.getByText( /OBSERVATION/ ) ).toBeVisible( );
} );
const firstObservation = await screen.findByTestId(
`ObsPressable.${mockObservations[0].uuid}`
@@ -133,31 +133,6 @@ describe( "logged in", ( ) => {
global.withAnimatedTimeTravelEnabled( { skipFakeTimers: true } );
describe( "from MyObs toolbar", ( ) => {
it( "should show observations view and navigate back to MyObs", async ( ) => {
renderApp( );
await waitFor( ( ) => {
global.timeTravel( );
expect( screen.getByText( /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( );

View File

@@ -64,7 +64,7 @@ const navigateToObsEditViaGroupPhotos = async ( ) => {
);
await waitFor( ( ) => {
global.timeTravel( );
expect( screen.getByText( /Welcome back/ ) ).toBeVisible( );
expect( screen.getByText( /OBSERVATIONS/ ) ).toBeVisible( );
} );
const tabBar = await screen.findByTestId( "CustomTabBar" );
const addObsButton = await within( tabBar ).findByLabelText( "Add observations" );

View File

@@ -2,7 +2,7 @@ import {
QueryClientProvider
} from "@tanstack/react-query";
import { fireEvent, screen, waitForElementToBeRemoved } from "@testing-library/react-native";
import MyObservationsContainer from "components/MyObservations/MyObservationsContainer";
import MyObservationsContainer from "components/MyObservations/MyObservationsContainer.tsx";
import React from "react";
import { measureRenders } from "reassure";
import safeRealmWrite from "sharedHelpers/safeRealmWrite";

View File

@@ -1,7 +1,8 @@
import { screen } from "@testing-library/react-native";
import MyObservations from "components/MyObservations/MyObservations";
import MyObservationsSimple, { OBSERVATIONS_TAB }
from "components/MyObservations/MyObservationsSimple.tsx";
import React from "react";
import DeviceInfo from "react-native-device-info";
// import DeviceInfo from "react-native-device-info";
import useDeviceOrientation from "sharedHooks/useDeviceOrientation.ts";
import factory from "tests/factory";
import { renderComponent } from "tests/helpers/render";
@@ -36,21 +37,21 @@ const DEVICE_ORIENTATION_PHONE_LANDSCAPE = {
screenHeight: 393
};
const DEVICE_ORIENTATION_TABLET_PORTRAIT = {
deviceOrientation: "portrait",
isTablet: true,
isLandscapeMode: false,
screenWidth: 820,
screenHeight: 1180
};
// const DEVICE_ORIENTATION_TABLET_PORTRAIT = {
// deviceOrientation: "portrait",
// isTablet: true,
// isLandscapeMode: false,
// screenWidth: 820,
// screenHeight: 1180
// };
const DEVICE_ORIENTATION_TABLET_LANDSCAPE = {
deviceOrientation: "landscapeLeft",
isTablet: true,
isLandscapeMode: true,
screenWidth: 1180,
screenHeight: 820
};
// const DEVICE_ORIENTATION_TABLET_LANDSCAPE = {
// deviceOrientation: "landscapeLeft",
// isTablet: true,
// isLandscapeMode: true,
// screenWidth: 1180,
// screenHeight: 820
// };
jest.mock( "sharedHooks/useDeviceOrientation.ts", ( ) => ( {
__esModule: true,
@@ -58,16 +59,17 @@ jest.mock( "sharedHooks/useDeviceOrientation.ts", ( ) => ( {
} ) );
const renderMyObservations = layout => renderComponent(
<MyObservations
<MyObservationsSimple
layout={layout}
observations={mockObservations}
onEndReached={jest.fn( )}
toggleLayout={jest.fn( )}
setShowLoginSheet={jest.fn( )}
activeTab={OBSERVATIONS_TAB}
/>
);
describe( "MyObservations", () => {
describe( "MyObservationsSimple", () => {
beforeAll( async () => {
jest.useFakeTimers( );
} );
@@ -115,17 +117,17 @@ describe( "MyObservations", () => {
expect( list.props.numColumns ).toEqual( 2 );
} );
} );
describe( "on a tablet", ( ) => {
beforeEach( ( ) => {
DeviceInfo.isTablet.mockReturnValue( true );
} );
it( "should have 4 columns", async ( ) => {
useDeviceOrientation.mockImplementation( ( ) => DEVICE_ORIENTATION_TABLET_PORTRAIT );
renderMyObservations( "grid" );
const list = screen.getByTestId( "MyObservationsAnimatedList" );
expect( list.props.numColumns ).toEqual( 4 );
} );
} );
// describe( "on a tablet", ( ) => {
// beforeEach( ( ) => {
// DeviceInfo.isTablet.mockReturnValue( true );
// } );
// it( "should have 4 columns", async ( ) => {
// useDeviceOrientation.mockImplementation( ( ) => DEVICE_ORIENTATION_TABLET_PORTRAIT );
// renderMyObservations( "grid" );
// const list = screen.getByTestId( "MyObservationsAnimatedList" );
// expect( list.props.numColumns ).toEqual( 4 );
// } );
// } );
} );
describe( "landscape orientation", ( ) => {
describe( "on a phone", ( ) => {
@@ -136,17 +138,17 @@ describe( "MyObservations", () => {
expect( list.props.numColumns ).toEqual( 2 );
} );
} );
describe( "on a tablet", ( ) => {
beforeEach( ( ) => {
DeviceInfo.isTablet.mockReturnValue( true );
} );
it( "should have 6 columns", async ( ) => {
useDeviceOrientation.mockImplementation( ( ) => DEVICE_ORIENTATION_TABLET_LANDSCAPE );
renderMyObservations( "grid" );
const list = screen.getByTestId( "MyObservationsAnimatedList" );
expect( list.props.numColumns ).toEqual( 6 );
} );
} );
// describe( "on a tablet", ( ) => {
// beforeEach( ( ) => {
// DeviceInfo.isTablet.mockReturnValue( true );
// } );
// it( "should have 6 columns", async ( ) => {
// useDeviceOrientation.mockImplementation( ( ) => DEVICE_ORIENTATION_TABLET_LANDSCAPE );
// renderMyObservations( "grid" );
// const list = screen.getByTestId( "MyObservationsAnimatedList" );
// expect( list.props.numColumns ).toEqual( 6 );
// } );
// } );
} );
} );
} );

View File

@@ -1,5 +1,5 @@
import { screen } from "@testing-library/react-native";
import ToolbarContainer from "components/MyObservations/ToolbarContainer";
import SimpleUploadBannerContainer from "components/MyObservations/SimpleUploadBannerContainer";
import React from "react";
import {
MANUAL_SYNC_IN_PROGRESS,
@@ -10,10 +10,10 @@ import {
UPLOAD_IN_PROGRESS,
UPLOAD_PENDING
} from "stores/createUploadObservationsSlice.ts";
import useStore from "stores/useStore";
import useStore, { zustandStorage } from "stores/useStore";
import { renderComponent } from "tests/helpers/render";
const initialStoreState = useStore.getState( );
const mockUser = {};
const deletionStore = {
currentDeleteCount: 1,
@@ -26,18 +26,18 @@ beforeAll( ( ) => {
jest.useFakeTimers( );
} );
describe( "Toolbar Container", () => {
beforeEach( ( ) => {
useStore.setState( initialStoreState, true );
} );
describe( "SimpleUploadBannerContainer", () => {
it( "displays syncing text before beginning uploads when sync button tapped", ( ) => {
useStore.setState( {
layout: {
isDefaultMode: false
},
isAdvancedUser: true,
numUnuploadedObservations: 1,
uploadStatus: UPLOAD_PENDING,
syncingStatus: MANUAL_SYNC_IN_PROGRESS
} );
renderComponent( <ToolbarContainer /> );
renderComponent( <SimpleUploadBannerContainer currentUser={mockUser} /> );
const statusText = screen.getByText( /Syncing.../ );
expect( statusText ).toBeVisible( );
@@ -45,11 +45,19 @@ describe( "Toolbar Container", () => {
it( "displays a pending upload", ( ) => {
useStore.setState( {
numUnuploadedObservations: 1,
layout: {
isDefaultMode: false
},
isAdvancedUser: true,
uploadStatus: UPLOAD_PENDING,
syncingStatus: SYNC_PENDING
} );
renderComponent( <ToolbarContainer /> );
renderComponent(
<SimpleUploadBannerContainer
numUploadableObservations={1}
currentUser={mockUser}
/>
);
const statusText = screen.getByText( /Upload 1 observation/ );
expect( statusText ).toBeVisible( );
@@ -57,12 +65,16 @@ describe( "Toolbar Container", () => {
it( "displays an upload in progress", ( ) => {
useStore.setState( {
layout: {
isDefaultMode: false
},
isAdvancedUser: true,
initialNumObservationsInQueue: 1,
numUploadsAttempted: 1,
uploadStatus: UPLOAD_IN_PROGRESS,
syncingStatus: SYNC_PENDING
} );
renderComponent( <ToolbarContainer /> );
renderComponent( <SimpleUploadBannerContainer currentUser={mockUser} /> );
const statusText = screen.getByText( /Uploading 1 observation/ );
expect( statusText ).toBeVisible( );
@@ -71,34 +83,36 @@ describe( "Toolbar Container", () => {
it( "displays a completed upload", () => {
const numUploadsAttempted = 1;
useStore.setState( {
layout: {
isDefaultMode: false
},
isAdvancedUser: true,
numUploadsAttempted,
uploadStatus: UPLOAD_COMPLETE,
syncingStatus: SYNC_PENDING,
initialNumObservationsInQueue: numUploadsAttempted
} );
renderComponent( <ToolbarContainer /> );
renderComponent( <SimpleUploadBannerContainer currentUser={mockUser} /> );
const statusText = screen.getByText( /1 observation uploaded/ );
expect( statusText ).toBeVisible( );
} );
it( "displays an upload error", () => {
const multiError = "Couldn't complete upload";
useStore.setState( {
multiError,
syncingStatus: SYNC_PENDING
} );
renderComponent( <ToolbarContainer /> );
expect( screen.getByText( multiError ) ).toBeVisible( );
} );
it( "displays multiple pending uploads", () => {
useStore.setState( {
numUnuploadedObservations: 4,
layout: {
isDefaultMode: false
},
isAdvancedUser: true,
uploadStatus: UPLOAD_PENDING,
syncingStatus: SYNC_PENDING
} );
renderComponent( <ToolbarContainer /> );
renderComponent(
<SimpleUploadBannerContainer
numUploadableObservations={4}
currentUser={mockUser}
/>
);
const statusText = screen.getByText( /Upload 4 observations/ );
expect( statusText ).toBeVisible( );
@@ -106,12 +120,16 @@ describe( "Toolbar Container", () => {
it( "displays multiple uploads in progress", () => {
useStore.setState( {
layout: {
isDefaultMode: false
},
isAdvancedUser: true,
uploadStatus: UPLOAD_IN_PROGRESS,
numUploadsAttempted: 2,
syncingStatus: SYNC_PENDING,
initialNumObservationsInQueue: 5
} );
renderComponent( <ToolbarContainer /> );
renderComponent( <SimpleUploadBannerContainer currentUser={mockUser} /> );
const statusText = screen.getByText( /Uploading 2 of 5 observations/ );
expect( statusText ).toBeVisible( );
@@ -120,12 +138,16 @@ describe( "Toolbar Container", () => {
it( "displays multiple completed uploads", () => {
const numUploadsAttempted = 7;
useStore.setState( {
layout: {
isDefaultMode: false
},
isAdvancedUser: true,
numUploadsAttempted,
uploadStatus: UPLOAD_COMPLETE,
syncingStatus: SYNC_PENDING,
initialNumObservationsInQueue: numUploadsAttempted
} );
renderComponent( <ToolbarContainer /> );
renderComponent( <SimpleUploadBannerContainer currentUser={mockUser} /> );
const statusText = screen.getByText( /7 observations uploaded/ );
expect( statusText ).toBeVisible( );
@@ -141,7 +163,7 @@ describe( "Toolbar Container", () => {
// ...deletionStore,
// syncingStatus: HANDLING_LOCAL_DELETIONS
// } );
// renderComponent( <ToolbarContainer /> );
// renderComponent( <SimpleUploadBannerContainer /> );
// const statusText = screen.getByText( /Deleting 1 of 1 observation/ );
// expect( statusText ).toBeVisible( );
@@ -149,12 +171,16 @@ describe( "Toolbar Container", () => {
it( "displays deletions completed", () => {
useStore.setState( {
layout: {
isDefaultMode: false
},
isAdvancedUser: true,
...deletionStore,
currentDeleteCount: 1,
deleteQueue: [{}],
initialNumDeletionsInQueue: 1
} );
renderComponent( <ToolbarContainer /> );
renderComponent( <SimpleUploadBannerContainer currentUser={mockUser} /> );
const statusText = screen.getByText( /1 observation deleted/ );
expect( statusText ).toBeVisible( );
@@ -163,11 +189,15 @@ describe( "Toolbar Container", () => {
it( "displays deletion error", ( ) => {
const deleteError = "Unknown problem deleting observations";
useStore.setState( {
layout: {
isDefaultMode: false
},
isAdvancedUser: true,
...deletionStore,
deleteError,
initialNumDeletionsInQueue: 2
} );
renderComponent( <ToolbarContainer /> );
renderComponent( <SimpleUploadBannerContainer currentUser={mockUser} /> );
const deletingText = screen.getByText( /Deleting/ );
expect( deletingText ).toBeVisible( );
@@ -175,4 +205,47 @@ describe( "Toolbar Container", () => {
const statusText = screen.getByText( deleteError );
expect( statusText ).toBeVisible( );
} );
it( "should hide banner if logged out and only one observation", ( ) => {
zustandStorage.setItem( "numOfUserObservations", 1 );
useStore.setState( {
layout: {
isDefaultMode: false
},
isAdvancedUser: true,
uploadStatus: UPLOAD_PENDING,
syncingStatus: SYNC_PENDING,
numOfUserObservations: 1
} );
renderComponent(
<SimpleUploadBannerContainer
numUploadableObservations={1}
currentUser={null}
/>
);
const statusText = screen.queryByText( /Upload 1 observation/ );
expect( statusText ).toBeFalsy( );
} );
it( "should show banner if logged out with more than one observation", ( ) => {
zustandStorage.setItem( "numOfUserObservations", 2 );
useStore.setState( {
layout: {
isDefaultMode: false
},
isAdvancedUser: true,
uploadStatus: UPLOAD_PENDING,
syncingStatus: SYNC_PENDING
} );
renderComponent(
<SimpleUploadBannerContainer
numUploadableObservations={1}
currentUser={null}
/>
);
const statusText = screen.getByText( /Upload 1 observation/ );
expect( statusText ).toBeVisible( );
} );
} );

View File

@@ -50,11 +50,11 @@ const mockRemoteObservation = factory( "RemoteObservation", {
taxon: factory.states( "genus" )( "RemoteTaxon" )
} );
const mockMutate = jest.fn();
const mockMutateAsync = jest.fn();
jest.mock( "sharedHooks/useAuthenticatedMutation", ( ) => ( {
__esModule: true,
default: ( ) => ( {
mutate: mockMutate
mutateAsync: mockMutateAsync
} )
} ) );
@@ -140,7 +140,7 @@ describe( "automatic sync while user is logged out", ( ) => {
await waitFor( ( ) => {
expect( deletedObs ).toBeFalsy( );
} );
expect( mockMutate ).not.toHaveBeenCalled( );
expect( mockMutateAsync ).not.toHaveBeenCalled( );
} );
} );
@@ -176,7 +176,7 @@ describe( "automatic sync while user is logged in", ( ) => {
renderHook( ( ) => useSyncObservations( currentUserId ) );
await waitFor( ( ) => {
expect( mockMutate ).not.toHaveBeenCalled( );
expect( mockMutateAsync ).not.toHaveBeenCalled( );
} );
} );
@@ -195,7 +195,7 @@ describe( "automatic sync while user is logged in", ( ) => {
renderHook( ( ) => useSyncObservations( currentUserId ) );
await waitFor( ( ) => {
expect( mockMutate )
expect( mockMutateAsync )
.toHaveBeenCalledWith( { uuid: syncedObservations[0].uuid } );
} );
} );