Changes to syncing process (#1670)

* Refactor syncing code and add preUploadProgress checks

* Progress on sync button functionality

* Fix deletions progress and toolbar test

* Time out deletion complete if no uploads

* Delete and then upload from sync button

* Test cleanup

* try to fix tests

* Improvements to automatic and button sync logging

* Code cleanup

* Renaming and rewriting sync functionality

* Restore MyObs test

* Add tests for useSyncObservations

* Code cleanup

* Use unique realm instance in useSyncObservations test

* Fixes for e2e crash

* Fix deletions, tests, and deletion errors

* Show and clear deletions on toolbar

* Sleep longer before resetting toolbar

* Remove sleep to pass e2e test
This commit is contained in:
Amanda Bullington
2024-06-18 19:04:28 -07:00
committed by GitHub
parent 76824636ef
commit 771e5c810b
20 changed files with 845 additions and 641 deletions

View File

@@ -127,12 +127,13 @@ module.exports = {
"react-native-a11y/has-valid-accessibility-ignores-invert-colors": 1,
"react-native-a11y/has-valid-accessibility-live-region": 1,
"react-native-a11y/has-valid-important-for-accessibility": 1,
"no-shadow": "off",
"@typescript-eslint/no-shadow": "error",
// TODO: we should actually type these at some point ~amanda 041824
"@typescript-eslint/ban-types": 0,
"@typescript-eslint/no-unused-vars": 0,
"@typescript-eslint/no-var-requires": 0
},
// need this so jest doesn't show as undefined in jest.setup.js
env: {

1
package-lock.json generated
View File

@@ -10289,6 +10289,7 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz",
"integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"array-includes": "^3.1.7",
"array.prototype.findlastindex": "^1.2.3",

View File

@@ -23,7 +23,6 @@ type Props = {
setShowLoginSheet: Function,
showLoginSheet: boolean,
status: string,
syncInProgress: boolean,
toggleLayout: Function
};
@@ -39,7 +38,6 @@ const MyObservations = ( {
setShowLoginSheet,
showLoginSheet,
status,
syncInProgress,
toggleLayout
}: Props ): Node => (
<>
@@ -53,7 +51,6 @@ const MyObservations = ( {
layout={layout}
logInButtonNeutral={observations.length === 0}
setHeightAboveToolbar={setStickyAt}
syncInProgress={syncInProgress}
toggleLayout={toggleLayout}
/>
)}

View File

@@ -1,19 +1,14 @@
// @flow
import { activateKeepAwake, deactivateKeepAwake } from "@sayem314/react-native-keep-awake";
import { searchObservations } from "api/observations";
import { getJWT } from "components/LoginSignUp/AuthenticationService";
import { useNavigation } from "@react-navigation/native";
import { RealmContext } from "providers/contexts";
import type { Node } from "react";
import React, {
useCallback,
useReducer, useState
useCallback, useEffect,
useState
} from "react";
import { Alert } from "react-native";
import Observation from "realmModels/Observation";
import { log } from "sharedHelpers/logger";
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
import { sleep } from "sharedHelpers/util";
import {
useCurrentUser,
useInfiniteObservationsScroll,
@@ -24,9 +19,7 @@ import {
useTranslation
} from "sharedHooks";
import {
UPLOAD_COMPLETE,
UPLOAD_IN_PROGRESS,
UPLOAD_PENDING
UPLOAD_IN_PROGRESS
} from "stores/createUploadObservationsSlice.ts";
import useStore from "stores/useStore";
@@ -34,37 +27,16 @@ import useClearGalleryPhotos from "./hooks/useClearGalleryPhotos";
import useClearRotatedOriginalPhotos from "./hooks/useClearRotatedOriginalPhotos";
import useClearSyncedPhotosForUpload from "./hooks/useClearSyncedPhotosForUpload";
import useClearSyncedSoundsForUpload from "./hooks/useClearSyncedSoundsForUpload";
import useDeleteObservations from "./hooks/useDeleteObservations";
import useSyncObservations from "./hooks/useSyncObservations";
import useUploadObservations from "./hooks/useUploadObservations";
import MyObservations from "./MyObservations";
const logger = log.extend( "MyObservationsContainer" );
export const INITIAL_STATE = {
canBeginDeletions: true,
syncInProgress: false
};
const reducer = ( state: Object, action: Function ): Object => {
switch ( action.type ) {
case "SET_START_DELETIONS":
return {
...state,
canBeginDeletions: false
};
case "START_SYNC":
return {
...state,
syncInProgress: true
};
default:
return state;
}
};
const { useRealm } = RealmContext;
const MyObservationsContainer = ( ): Node => {
const navigation = useNavigation( );
// clear original, large-sized photos before a user returns to any of the Camera or AICamera flows
useClearRotatedOriginalPhotos( );
useClearGalleryPhotos( );
@@ -72,26 +44,27 @@ const MyObservationsContainer = ( ): Node => {
useClearSyncedSoundsForUpload( );
const { t } = useTranslation( );
const realm = useRealm( );
const resetUploadObservationsSlice = useStore( state => state?.resetUploadObservationsSlice );
const uploadStatus = useStore( state => state.uploadStatus );
const setUploadStatus = useStore( state => state.setUploadStatus );
const numUnuploadedObservations = useStore( state => state.numUnuploadedObservations );
const addToUploadQueue = useStore( state => state.addToUploadQueue );
const addTotalToolbarIncrements = useStore( state => state.addTotalToolbarIncrements );
const setTotalToolbarIncrements = useStore( state => state.setTotalToolbarIncrements );
const [state, dispatch] = useReducer( reducer, INITIAL_STATE );
const { observationList: observations, unsyncedUuids } = useLocalObservations( );
const { layout, writeLayoutToStorage } = useStoredLayout( "myObservationsLayout" );
const syncingStatus = useStore( state => state.syncingStatus );
const resetSyncObservationsSlice
= useStore( state => state.resetSyncObservationsSlice );
const startManualSync = useStore( state => state.startManualSync );
const startAutomaticSync = useStore( state => state.startAutomaticSync );
const deletionsCompletedAt = useStore( s => s.deletionsCompletedAt );
const { observationList: observations } = useLocalObservations( );
const { layout, writeLayoutToStorage } = useStoredLayout( "myObservationsLayout" );
const isOnline = useIsConnected( );
const currentUser = useCurrentUser( );
const currentUserId = currentUser?.id;
const canUpload = currentUser && isOnline;
useDeleteObservations(
currentUser?.id && state.canBeginDeletions,
dispatch
const { uploadObservations } = useUploadObservations( canUpload );
useSyncObservations(
currentUserId,
uploadObservations
);
useObservationsUpdates( !!currentUser );
@@ -104,7 +77,7 @@ const MyObservationsContainer = ( ): Node => {
} = useInfiniteObservationsScroll( {
upsert: true,
params: {
user_id: currentUser?.id
user_id: currentUserId
}
} );
@@ -116,156 +89,74 @@ const MyObservationsContainer = ( ): Node => {
: "grid" );
};
const showInternetErrorAlert = useCallback( ( ) => {
const confirmInternetConnection = useCallback( ( ) => {
if ( !isOnline ) {
Alert.alert(
t( "Internet-Connection-Required" ),
t( "Please-try-again-when-you-are-connected-to-the-internet" )
);
}
return isOnline;
}, [t, isOnline] );
const toggleLoginSheet = useCallback( ( ) => {
const confirmLoggedIn = useCallback( ( ) => {
if ( !currentUser ) {
setShowLoginSheet( true );
}
return currentUser;
}, [currentUser] );
const startUpload = useCallback( ( ) => {
toggleLoginSheet( );
showInternetErrorAlert( );
if ( canUpload ) {
setUploadStatus( UPLOAD_IN_PROGRESS );
} else {
setUploadStatus( UPLOAD_PENDING );
}
}, [
canUpload,
setUploadStatus,
showInternetErrorAlert,
toggleLoginSheet
] );
const {
syncInProgress
} = state;
useUploadObservations( );
const downloadRemoteObservationsFromServer = useCallback( async ( ) => {
const apiToken = await getJWT( );
const searchParams = {
user_id: currentUser?.id,
per_page: 50,
fields: Observation.FIELDS,
ttl: -1
};
// Between elasticsearch update time and API caches, there's no absolute
// guarantee fetching observations won't include something we just
// deleted, so we check to see if deletions recently completed and if
// they did, make sure 10s have elapsed since deletions complated before
// fetching new obs
if ( deletionsCompletedAt ) {
const msSinceDeletionsCompleted = ( new Date( ) - deletionsCompletedAt );
if ( msSinceDeletionsCompleted < 5_000 ) {
const naptime = 10_000 - msSinceDeletionsCompleted;
logger.info(
"downloadRemoteObservationsFromServer finished deleting "
+ `recently deleted, waiting ${naptime} ms`
);
await sleep( naptime );
}
}
logger.info(
"downloadRemoteObservationsFromServer, fetching observations"
);
const { results } = await searchObservations( searchParams, { api_token: apiToken } );
logger.info(
"downloadRemoteObservationsFromServer, fetched",
results.length,
"results, upserting..."
);
Observation.upsertRemoteObservations( results, realm );
}, [
currentUser,
deletionsCompletedAt,
realm
] );
const updateSyncTime = useCallback( ( ) => {
const localPrefs = realm.objects( "LocalPreferences" )[0];
const updatedPrefs = {
...localPrefs,
last_sync_time: new Date( )
};
safeRealmWrite( realm, ( ) => {
realm.create( "LocalPreferences", updatedPrefs, "modified" );
}, "updating sync time in MyObservationsContainer" );
}, [realm] );
const syncObservations = useCallback( async ( ) => {
if ( uploadStatus === UPLOAD_COMPLETE ) {
resetUploadObservationsSlice( );
}
if ( !currentUser ) {
toggleLoginSheet( );
resetUploadObservationsSlice( );
return;
}
if ( !isOnline ) {
showInternetErrorAlert( );
resetUploadObservationsSlice( );
return;
}
dispatch( { type: "START_SYNC" } );
activateKeepAwake( );
await downloadRemoteObservationsFromServer( );
updateSyncTime( );
deactivateKeepAwake( );
resetUploadObservationsSlice( );
}, [
currentUser,
downloadRemoteObservationsFromServer,
isOnline,
showInternetErrorAlert,
toggleLoginSheet,
updateSyncTime,
uploadStatus,
resetUploadObservationsSlice
] );
const handleSyncButtonPress = useCallback( ( ) => {
if ( numUnuploadedObservations > 0 ) {
const uuidsQuery = unsyncedUuids.map( uploadUuid => `'${uploadUuid}'` ).join( ", " );
const uploads = realm.objects( "Observation" )
.filtered( `uuid IN { ${uuidsQuery} }` );
setTotalToolbarIncrements( uploads );
addToUploadQueue( unsyncedUuids );
startUpload( );
} else {
syncObservations( );
}
logger.debug( "Manual sync starting: user tapped sync button" );
if ( !confirmLoggedIn( ) ) { return; }
if ( !confirmInternetConnection( ) ) { return; }
startManualSync( );
}, [
addToUploadQueue,
startUpload,
numUnuploadedObservations,
realm,
setTotalToolbarIncrements,
syncObservations,
unsyncedUuids
startManualSync,
confirmInternetConnection,
confirmLoggedIn
] );
const handleIndividualUploadPress = useCallback( uuid => {
logger.debug( "Starting individual upload:", uuid );
if ( !confirmLoggedIn( ) ) { return; }
if ( !confirmInternetConnection( ) ) { return; }
const observation = realm.objectForPrimaryKey( "Observation", uuid );
addTotalToolbarIncrements( observation );
addToUploadQueue( uuid );
startUpload( );
setUploadStatus( UPLOAD_IN_PROGRESS );
}, [
addToUploadQueue,
confirmLoggedIn,
confirmInternetConnection,
realm,
addTotalToolbarIncrements,
startUpload,
realm
addToUploadQueue,
setUploadStatus
] );
useEffect( ( ) => {
// this is intended to have the automatic sync run once
// the very first time a user lands on MyObservations
startAutomaticSync( );
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [] );
useEffect( ( ) => {
// this is intended to have the automatic sync run once
// every time a user lands on MyObservations from a different tab screen.
// ideally, we wouldn't need both this and the useEffect hook above
navigation.addListener( "focus", ( ) => {
startAutomaticSync( );
} );
navigation.addListener( "blur", ( ) => {
resetSyncObservationsSlice( );
} );
}, [
navigation,
startAutomaticSync,
syncingStatus,
resetSyncObservationsSlice
] );
if ( !layout ) { return null; }
@@ -289,7 +180,6 @@ const MyObservationsContainer = ( ): Node => {
setShowLoginSheet={setShowLoginSheet}
showLoginSheet={showLoginSheet}
status={observationListStatus}
syncInProgress={syncInProgress}
toggleLayout={toggleLayout}
/>
);

View File

@@ -25,7 +25,6 @@ type Props = {
layout: string,
logInButtonNeutral: boolean,
setHeightAboveToolbar: Function,
syncInProgress: boolean,
toggleLayout: Function
}
@@ -36,7 +35,6 @@ const MyObservationsHeader = ( {
layout,
logInButtonNeutral,
setHeightAboveToolbar,
syncInProgress,
toggleLayout
}: Props ): Node => {
const numUnuploadedObservations = useStore( state => state.numUnuploadedObservations );
@@ -125,7 +123,6 @@ const MyObservationsHeader = ( {
<ToolbarContainer
handleSyncButtonPress={handleSyncButtonPress}
layout={layout}
syncInProgress={syncInProgress}
toggleLayout={toggleLayout}
/>
)}

View File

@@ -9,6 +9,9 @@ import {
useCurrentUser,
useTranslation
} from "sharedHooks";
import {
SYNC_PENDING
} from "stores/createSyncObservationsSlice.ts";
import {
UPLOAD_COMPLETE,
UPLOAD_IN_PROGRESS,
@@ -20,33 +23,32 @@ import Toolbar from "./Toolbar";
const screenWidth = Dimensions.get( "window" ).width * PixelRatio.get( );
const DELETION_STARTED_PROGRESS = 0.25;
type Props = {
handleSyncButtonPress: Function,
layout: string,
syncInProgress: boolean,
toggleLayout: Function
}
const ToolbarContainer = ( {
handleSyncButtonPress,
layout,
syncInProgress,
toggleLayout
}: Props ): Node => {
const setExploreView = useStore( state => state.setExploreView );
const currentUser = useCurrentUser( );
const navigation = useNavigation( );
const deletions = useStore( state => state.deletions );
const deletionsComplete = useStore( state => state.deletionsComplete );
const currentDeleteCount = useStore( state => state.currentDeleteCount );
const deleteError = useStore( state => state.deleteError );
const deletionsInProgress = useStore( state => state.deletionsInProgress );
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 );
@@ -62,18 +64,6 @@ const ToolbarContainer = ( {
numUploadsAttempted
] );
const totalDeletions = deletions.length;
const deletionsProgress = totalDeletions > 0
? currentDeleteCount / totalDeletions
: 0;
const deletionParams = useMemo( ( ) => ( {
total: totalDeletions,
currentDeleteCount
} ), [
totalDeletions,
currentDeleteCount
] );
const navToExplore = useCallback(
( ) => {
setExploreView( "observations" );
@@ -89,26 +79,49 @@ const ToolbarContainer = ( {
const { t } = useTranslation( );
const theme = useTheme( );
const deletionsComplete = initialNumDeletionsInQueue === currentDeleteCount;
const deletionsInProgress = initialNumDeletionsInQueue > 0 && !deletionsComplete;
const syncInProgress = syncingStatus !== SYNC_PENDING;
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 ) );
|| ( totalUploadErrors > 0 && ( numUploadsAttempted === initialNumObservationsInQueue ) );
const rotating = syncInProgress || uploadInProgress || deletionsInProgress;
const showsCheckmark = ( uploadsComplete && !uploadMultiError )
|| ( deletionsComplete && !deleteError );
|| ( deletionsComplete && !deleteError && initialNumDeletionsInQueue > 0 );
const showsExclamation = pendingUpload || showFinalUploadError;
const getStatusText = useCallback( ( ) => {
if ( syncInProgress ) { return t( "Syncing" ); }
if ( totalDeletions > 0 ) {
const deletionParams = {
total: initialNumDeletionsInQueue,
currentDeleteCount
};
if ( initialNumDeletionsInQueue > 0 ) {
if ( deletionsComplete ) {
return t( "X-observations-deleted", { count: totalDeletions } );
return t( "X-observations-deleted", { count: initialNumDeletionsInQueue } );
}
// iPhone 4 pixel width
return screenWidth <= 640
@@ -133,14 +146,14 @@ const ToolbarContainer = ( {
return "";
}, [
deletionParams,
currentDeleteCount,
deletionsComplete,
initialNumDeletionsInQueue,
numUploadsAttempted,
numUnuploadedObservations,
pendingUpload,
syncInProgress,
t,
totalDeletions,
translationParams,
uploadInProgress,
uploadsComplete
@@ -187,7 +200,7 @@ const ToolbarContainer = ( {
handleSyncButtonPress={handleSyncButtonPress}
layout={layout}
navToExplore={navToExplore}
progress={deletionsProgress || totalToolbarProgress}
progress={totalToolbarProgress || deletionsProgress}
rotating={rotating}
showsCancelUploadButton={uploadInProgress}
showsCheckmark={showsCheckmark}

View File

@@ -1,7 +0,0 @@
export default filterLocalObservationsToDelete = realm => {
// currently sorting so oldest observations to delete are first
const observationsFlaggedForDeletion = realm
.objects( "Observation" ).filtered( "_deleted_at != nil" ).sorted( "_deleted_at", false );
return observationsFlaggedForDeletion.map( obs => obs.toJSON( ) );
};

View File

@@ -0,0 +1,56 @@
import { searchObservations } from "api/observations";
import { getJWT } from "components/LoginSignUp/AuthenticationService";
import Observation from "realmModels/Observation";
import { log } from "sharedHelpers/logger";
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
import { sleep } from "sharedHelpers/util";
const logger = log.extend( "syncRemoteObservations.ts" );
const updateSyncTime = realm => {
const localPrefs = realm?.objects( "LocalPreferences" )[0];
const updatedPrefs = {
...localPrefs,
last_sync_time: new Date( )
};
safeRealmWrite( realm, ( ) => {
realm?.create( "LocalPreferences", updatedPrefs, "modified" );
}, "updating sync time in syncRemoteObservations" );
};
export default syncRemoteObservations = async ( realm, currentUserId, deletionsCompletedAt ) => {
const apiToken = await getJWT( );
const searchParams = {
user_id: currentUserId,
per_page: 50,
fields: Observation.LIST_FIELDS,
ttl: -1
};
// Between elasticsearch update time and API caches, there's no absolute
// guarantee fetching observations won't include something we just
// deleted, so we check to see if deletions recently completed and if
// they did, make sure 10s have elapsed since deletions complated before
// fetching new obs
if ( deletionsCompletedAt ) {
const msSinceDeletionsCompleted = ( new Date( ) - deletionsCompletedAt );
if ( msSinceDeletionsCompleted < 5_000 ) {
const naptime = 10_000 - msSinceDeletionsCompleted;
logger.debug(
"downloadRemoteObservationsFromServer finished deleting "
+ `recently deleted, waiting ${naptime} ms`
);
await sleep( naptime );
}
}
logger.debug(
"downloadRemoteObservationsFromServer, fetching observations"
);
const { results } = await searchObservations( searchParams, { api_token: apiToken } );
logger.debug(
"downloadRemoteObservationsFromServer, fetched",
results.length,
"results, upserting..."
);
updateSyncTime( realm );
await Observation.upsertRemoteObservations( results, realm );
};

View File

@@ -1,177 +0,0 @@
import { useNavigation } from "@react-navigation/native";
import { INatApiError } from "api/error";
import { deleteRemoteObservation } from "api/observations";
import { RealmContext } from "providers/contexts";
import { useCallback, useEffect } from "react";
import Observation from "realmModels/Observation";
import { log } from "sharedHelpers/logger";
import { useAuthenticatedMutation } from "sharedHooks";
import useStore from "stores/useStore";
import filterLocalObservationsToDelete from "../helpers/filterLocalObservationsToDelete";
import syncRemoteDeletedObservations from "../helpers/syncRemoteDeletedObservations";
const logger = log.extend( "useDeleteObservations" );
const { useRealm } = RealmContext;
const useDeleteObservations = ( canBeginDeletions, myObservationsDispatch ): Object => {
const deletions = useStore( state => state.deletions );
const deletionsComplete = useStore( state => state.deletionsComplete );
const deletionsInProgress = useStore( state => state.deletionsInProgress );
const currentDeleteCount = useStore( state => state.currentDeleteCount );
const error = useStore( state => state.deleteError );
const finishDeletions = useStore( state => state.finishDeletions );
const resetDeleteObservationsSlice = useStore( state => state.resetDeleteObservationsSlice );
const setDeletions = useStore( state => state.setDeletions );
const startNextDeletion = useStore( state => state.startNextDeletion );
const setDeletionError = useStore( state => state.setDeletionError );
const realm = useRealm( );
const navigation = useNavigation( );
const observationToDelete = deletions[currentDeleteCount - 1];
const uuid = observationToDelete?.uuid;
const canStartDeletingLocalObservations = deletions.length > 0
&& !deletionsInProgress
&& !deletionsComplete;
const deleteLocalObservation = useCallback( async ( ) => {
await Observation.deleteLocalObservation( realm, uuid );
return true;
}, [realm, uuid] );
const deleteObservationMutation = useAuthenticatedMutation(
( params, optsWithAuth ) => deleteRemoteObservation( params, optsWithAuth ),
{
onSuccess: ( ) => {
logger.info(
"Remote observation deleted, now deleting local observation"
);
deleteLocalObservation( );
},
onError: deleteObservationError => {
// If we tried to delete and got a 404, this observation doesn't exist
// on the server any more and should be deleted locally
if ( deleteObservationError?.status === 404 ) {
deleteLocalObservation( );
} else {
throw deleteObservationError;
}
}
}
);
const deleteObservation = useCallback( async ( ) => {
if ( !observationToDelete?._synced_at ) {
return deleteLocalObservation( );
}
logger.info( "Remote observation to delete: ", uuid );
return deleteObservationMutation.mutate( { uuid } );
}, [deleteLocalObservation, deleteObservationMutation, observationToDelete, uuid] );
const deleteRemoteObservationAndCatchError = useCallback( async ( ) => {
try {
return deleteObservation( );
} catch ( deleteError ) {
console.warn( "useDeleteObservations, deleteError: ", deleteError );
let { message } = deleteError;
if ( deleteError?.json?.errors ) {
// TODO localize comma join
message = deleteError.json.errors.map( e => {
if ( e.message?.errors ) {
return e.message.errors.flat( ).join( ", " );
}
return e.message;
} ).join( ", " );
} else {
throw deleteError;
}
setDeletionError( message );
return false;
}
}, [deleteObservation, setDeletionError] );
useEffect( ( ) => {
const beginDeletions = async ( ) => {
// logger.info( "syncing remotely deleted observations" );
try {
await syncRemoteDeletedObservations( realm );
} catch ( syncRemoteError ) {
// For some reason this seems to run even when signed out, in which
// case we end up sending no JWT or the anon JWT, wich fails auth. If
// that happens, we can just return and call it a day.
if (
syncRemoteError instanceof INatApiError
&& syncRemoteError?.status === 401
) {
return;
}
throw syncRemoteError;
}
// logger.info( "syncing locally deleted observations" );
const localObservations = filterLocalObservationsToDelete( realm );
if ( localObservations.length > 0 && deletions.length === 0 ) {
setDeletions( localObservations );
}
};
if ( canBeginDeletions ) {
myObservationsDispatch( { type: "SET_START_DELETIONS" } );
beginDeletions( );
}
}, [
canBeginDeletions,
deletions,
myObservationsDispatch,
realm,
setDeletions
] );
useEffect( ( ) => {
if ( canStartDeletingLocalObservations ) {
deletions.forEach( async ( observation, i ) => {
await deleteRemoteObservationAndCatchError( );
if ( i > 0 ) {
startNextDeletion( );
}
if ( i === deletions.length - 1 ) {
finishDeletions( );
}
} );
}
}, [
canStartDeletingLocalObservations,
deleteRemoteObservationAndCatchError,
deletions,
finishDeletions,
startNextDeletion
] );
useEffect(
( ) => {
navigation.addListener( "blur", ( ) => {
resetDeleteObservationsSlice( );
} );
},
[navigation, resetDeleteObservationsSlice]
);
useEffect( ( ) => {
let timer;
if ( deletionsComplete && !error ) {
timer = setTimeout( ( ) => {
resetDeleteObservationsSlice( );
}, 5000 );
}
return ( ) => {
clearTimeout( timer );
};
}, [deletionsComplete, error, resetDeleteObservationsSlice] );
return {
deleteLocalObservation
};
};
export default useDeleteObservations;

View File

@@ -0,0 +1,185 @@
import { INatApiError } from "api/error";
import { deleteRemoteObservation } from "api/observations";
import { RealmContext } from "providers/contexts";
import { useCallback, useEffect } from "react";
import Observation from "realmModels/Observation";
import { log } from "sharedHelpers/logger";
import useAuthenticatedMutation from "sharedHooks/useAuthenticatedMutation";
import {
AUTOMATIC_SYNC_IN_PROGRESS,
BEGIN_AUTOMATIC_SYNC,
BEGIN_MANUAL_SYNC,
MANUAL_SYNC_IN_PROGRESS
} from "stores/createSyncObservationsSlice.ts";
import useStore from "stores/useStore";
import syncRemoteDeletedObservations from "../helpers/syncRemoteDeletedObservations";
import syncRemoteObservations from "../helpers/syncRemoteObservations";
const logger = log.extend( "useSyncObservations" );
const { useRealm } = RealmContext;
const useSyncObservations = ( currentUserId, uploadObservations ): Object => {
const loggedIn = !!currentUserId;
const deleteQueue = useStore( state => state.deleteQueue );
const deletionsCompletedAt = useStore( state => state.deletionsCompletedAt );
const completeLocalDeletions = useStore( state => state.completeLocalDeletions );
const startNextDeletion = useStore( state => state.startNextDeletion );
const setDeletionError = useStore( state => state.setDeletionError );
const syncingStatus = useStore( state => state.syncingStatus );
const setSyncingStatus = useStore( state => state.setSyncingStatus );
const completeSync = useStore( state => state.completeSync );
const resetSyncToolbar = useStore( state => state.resetSyncToolbar );
const removeFromDeleteQueue = useStore( state => state.removeFromDeleteQueue );
const realm = useRealm( );
const deleteRealmObservation = useCallback( async uuid => {
await Observation.deleteLocalObservation( realm, uuid );
}, [realm] );
const handleRemoteDeletion = useAuthenticatedMutation(
( params, optsWithAuth ) => deleteRemoteObservation( params, optsWithAuth ),
{
onSuccess: ( ) => logger.debug( "Remote observation deleted" ),
onError: deleteObservationError => {
setDeletionError( deleteObservationError?.message );
throw deleteObservationError;
}
}
);
const deleteLocalObservations = useCallback( async ( ) => {
if ( deleteQueue.length === 0 ) { return; }
deleteQueue.forEach( async ( uuid, i ) => {
const observation = realm.objectForPrimaryKey( "Observation", uuid );
const canDeleteRemoteObservation = observation?._synced_at && loggedIn;
if ( !canDeleteRemoteObservation ) {
return deleteRealmObservation( uuid );
}
await handleRemoteDeletion.mutate( { uuid } );
removeFromDeleteQueue( );
if ( i > 0 ) {
// this loop isn't really being used, since a user can only delete one
// observation at a time in soft launch
startNextDeletion( );
}
if ( i === deleteQueue.length - 1 ) {
completeLocalDeletions( );
}
return null;
} );
}, [
completeLocalDeletions,
deleteQueue,
deleteRealmObservation,
handleRemoteDeletion,
loggedIn,
realm,
removeFromDeleteQueue,
startNextDeletion
] );
const fetchRemoteDeletions = useCallback( async ( ) => {
try {
await syncRemoteDeletedObservations( realm );
return true;
} catch ( syncRemoteError ) {
// For some reason this seems to run even when signed out, in which
// case we end up sending no JWT or the anon JWT, wich fails auth. If
// that happens, we can just return and call it a day.
if (
syncRemoteError instanceof INatApiError
&& syncRemoteError?.status === 401
) {
return false;
}
throw syncRemoteError;
}
}, [
realm
] );
const fetchRemoteObservations = useCallback( async ( ) => {
try {
await syncRemoteObservations( realm, currentUserId, deletionsCompletedAt );
return true;
} catch ( syncRemoteError ) {
if (
syncRemoteError instanceof INatApiError
&& syncRemoteError?.status === 401
) {
return false;
}
throw syncRemoteError;
}
}, [
realm,
currentUserId,
deletionsCompletedAt
] );
const syncAutomatically = useCallback( async ( ) => {
if ( loggedIn ) {
logger.debug( "sync #1: syncing remotely deleted observations" );
await fetchRemoteDeletions( );
}
logger.debug( "sync #2: handling locally deleted observations" );
await deleteLocalObservations( );
if ( loggedIn ) {
logger.debug( "sync #3: fetching remote observations" );
await fetchRemoteObservations( );
}
completeSync( );
}, [
deleteLocalObservations,
fetchRemoteDeletions,
fetchRemoteObservations,
loggedIn,
completeSync
] );
const syncManually = useCallback( async ( ) => {
if ( loggedIn ) {
logger.debug( "sync #1: syncing remotely deleted observations" );
await fetchRemoteDeletions( );
}
logger.debug( "sync #2: handling locally deleted observations" );
await deleteLocalObservations( );
if ( loggedIn ) {
logger.debug( "sync #3: fetching remote observations" );
await fetchRemoteObservations( );
}
resetSyncToolbar( );
if ( loggedIn ) {
logger.debug( "sync #4: uploading all unsynced observations" );
await uploadObservations( );
}
completeSync( );
}, [
deleteLocalObservations,
fetchRemoteDeletions,
fetchRemoteObservations,
loggedIn,
resetSyncToolbar,
completeSync,
uploadObservations
] );
useEffect( ( ) => {
if ( syncingStatus !== BEGIN_AUTOMATIC_SYNC ) { return; }
setSyncingStatus( AUTOMATIC_SYNC_IN_PROGRESS );
syncAutomatically( );
}, [syncingStatus, syncAutomatically, setSyncingStatus] );
useEffect( ( ) => {
if ( syncingStatus !== BEGIN_MANUAL_SYNC ) { return; }
setSyncingStatus( MANUAL_SYNC_IN_PROGRESS );
syncManually( );
}, [syncingStatus, syncManually, setSyncingStatus] );
};
export default useSyncObservations;

View File

@@ -9,23 +9,28 @@ import Observation from "realmModels/Observation";
import {
INCREMENT_SINGLE_UPLOAD_PROGRESS
} from "sharedHelpers/emitUploadProgress";
import { log } from "sharedHelpers/logger";
import uploadObservation, { handleUploadError } from "sharedHelpers/uploadObservation";
import {
useLocalObservations,
useTranslation
} from "sharedHooks";
import {
UPLOAD_CANCELLED,
UPLOAD_COMPLETE,
UPLOAD_IN_PROGRESS
UPLOAD_IN_PROGRESS,
UPLOAD_PENDING
} from "stores/createUploadObservationsSlice.ts";
import useStore from "stores/useStore";
const logger = log.extend( "useUploadObservations" );
export const MS_BEFORE_TOOLBAR_RESET = 5_000;
const MS_BEFORE_UPLOAD_TIMES_OUT = 60_000 * 5;
const { useRealm } = RealmContext;
export default useUploadObservations = ( ) => {
export default useUploadObservations = canUpload => {
const realm = useRealm( );
const addUploadError = useStore( state => state.addUploadError );
@@ -41,8 +46,14 @@ export default useUploadObservations = ( ) => {
const uploadQueue = useStore( state => state.uploadQueue );
const uploadStatus = useStore( state => state.uploadStatus );
const setNumUnuploadedObservations = useStore( state => state.setNumUnuploadedObservations );
const setTotalToolbarIncrements = useStore( state => state.setTotalToolbarIncrements );
const addToUploadQueue = useStore( state => state.addToUploadQueue );
const setUploadStatus = useStore( state => state.setUploadStatus );
const resetSyncToolbar = useStore( state => state.resetSyncToolbar );
const initialNumObservationsInQueue = useStore( state => state.initialNumObservationsInQueue );
const { unsyncedUuids } = useLocalObservations( );
// The existing abortController lets you abort...
const abortController = useStore( storeState => storeState.abortController );
// ...but whenever you start a new abortable upload process, you need to
@@ -60,12 +71,19 @@ export default useUploadObservations = ( ) => {
const unsynced = Observation.filterUnsyncedObservations( realm );
setNumUnuploadedObservations( unsynced.length );
}, MS_BEFORE_TOOLBAR_RESET );
} else {
timer = setTimeout( () => {
resetSyncToolbar( );
const unsynced = Observation.filterUnsyncedObservations( realm );
setNumUnuploadedObservations( unsynced.length );
}, MS_BEFORE_TOOLBAR_RESET );
}
return () => {
clearTimeout( timer );
};
}, [
realm,
resetSyncToolbar,
resetUploadObservationsSlice,
setNumUnuploadedObservations,
uploadStatus
@@ -183,4 +201,42 @@ export default useUploadObservations = ( ) => {
deactivateKeepAwake( );
}
}, [abortController, uploadStatus] );
const startUpload = useCallback( ( ) => {
if ( canUpload ) {
logger.debug( "sync #4.2: starting upload" );
setUploadStatus( UPLOAD_IN_PROGRESS );
} else {
setUploadStatus( UPLOAD_PENDING );
}
}, [
canUpload,
setUploadStatus
] );
const createUploadQueue = useCallback( ( ) => {
const uuidsQuery = unsyncedUuids.map( uploadUuid => `'${uploadUuid}'` ).join( ", " );
const uploads = realm.objects( "Observation" )
.filtered( `uuid IN { ${uuidsQuery} }` );
setTotalToolbarIncrements( uploads );
addToUploadQueue( unsyncedUuids );
startUpload( );
}, [
realm,
setTotalToolbarIncrements,
unsyncedUuids,
addToUploadQueue,
startUpload
] );
const uploadObservations = useCallback( async ( ) => {
logger.debug( "sync #4.1: creating upload queue" );
createUploadQueue( );
}, [
createUploadQueue
] );
return {
uploadObservations
};
};

View File

@@ -8,6 +8,7 @@ import type { Node } from "react";
import React, { useCallback } from "react";
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
import { useTranslation } from "sharedHooks";
import useStore from "stores/useStore";
const { useRealm } = RealmContext;
@@ -29,6 +30,7 @@ const DeleteObservationSheet = ( {
const { t } = useTranslation( );
const realm = useRealm( );
const { uuid } = currentObservation;
const addToDeleteQueue = useStore( state => state.addToDeleteQueue );
const multipleObservations = observations.length > 1;
@@ -38,6 +40,7 @@ const DeleteObservationSheet = ( {
safeRealmWrite( realm, ( ) => {
localObsToDelete._deleted_at = new Date( );
}, "adding _deleted_at date in DeleteObservationSheet" );
addToDeleteQueue( uuid );
}
if ( multipleObservations ) {
updateObservations( observations.filter( o => o.uuid !== uuid ) );
@@ -51,6 +54,7 @@ const DeleteObservationSheet = ( {
navToObsList,
observations,
realm,
addToDeleteQueue,
updateObservations,
uuid
] );

View File

@@ -1,37 +0,0 @@
const DEFAULT_STATE = {
currentDeleteCount: 1,
deletions: [],
deletionsComplete: false,
deletionsCompletedAt: null,
deletionsInProgress: false,
deleteError: null
};
interface DeleteObservationsSlice {
currentDeleteCount: number,
deletions: Array<Object>,
deletionsComplete: boolean,
deletionsCompletedAt: Date,
deletionsInProgress: boolean,
deleteError: string | null
}
const createDeleteObservationsSlice: StateCreator<DeleteObservationsSlice> = set => ( {
...DEFAULT_STATE,
setDeletions: deletions => set( ( ) => ( { deletions } ) ),
startNextDeletion: ( ) => set( state => ( {
currentDeleteCount: state.currentDeleteCount + 1,
deletionsInProgress: true
} ) ),
finishDeletions: ( ) => set( ( ) => ( {
deletionsInProgress: false,
deletionsComplete: true,
deletionsCompletedAt: new Date( )
} ) ),
resetDeleteObservationsSlice: ( ) => set( DEFAULT_STATE ),
setDeletionError: message => set( ( ) => ( {
deleteError: message
} ) )
} );
export default createDeleteObservationsSlice;

View File

@@ -0,0 +1,95 @@
import { activateKeepAwake, deactivateKeepAwake } from "@sayem314/react-native-keep-awake";
export const SYNC_PENDING = "sync-pending";
export const BEGIN_MANUAL_SYNC = "begin-manual-sync";
export const BEGIN_AUTOMATIC_SYNC = "begin-automatic-sync";
export const MANUAL_SYNC_IN_PROGRESS = "manual-sync-progress";
export const AUTOMATIC_SYNC_IN_PROGRESS = "automatic-sync-progress";
const DEFAULT_STATE = {
currentDeleteCount: 1,
deleteError: null,
deleteQueue: [],
deletionsCompletedAt: null,
initialNumDeletionsInQueue: 0,
syncingStatus: SYNC_PENDING
};
interface SyncObservationsSlice {
currentDeleteCount: number,
deleteError: string | null,
deleteQueue: Array<string>,
deletionsCompletedAt: Date,
initialNumDeletionsInQueue: number,
syncingStatus: typeof SYNC_PENDING
| typeof AUTOMATIC_SYNC_IN_PROGRESS
| typeof MANUAL_SYNC_IN_PROGRESS
}
const createSyncObservationsSlice: StateCreator<SyncObservationsSlice> = set => ( {
...DEFAULT_STATE,
addToDeleteQueue: uuids => set( state => {
let copyOfDeleteQueue = state.deleteQueue;
if ( typeof uuids === "string" ) {
copyOfDeleteQueue.push( uuids );
} else {
copyOfDeleteQueue = copyOfDeleteQueue.concat( uuids );
}
return ( {
deleteQueue: copyOfDeleteQueue,
initialNumDeletionsInQueue: state.initialNumDeletionsInQueue
+ ( typeof uuids === "string"
? 1
: uuids.length )
} );
} ),
removeFromDeleteQueue: ( ) => set( state => {
const copyOfDeleteQueue = state.deleteQueue;
copyOfDeleteQueue.pop( );
return ( {
deleteQueue: copyOfDeleteQueue
} );
} ),
startNextDeletion: ( ) => set( state => ( {
currentDeleteCount: state.currentDeleteCount + 1
} ) ),
completeLocalDeletions: ( ) => set( ( ) => ( {
deletionsCompletedAt: new Date( )
} ) ),
resetSyncObservationsSlice: ( ) => set( DEFAULT_STATE ),
setDeletionError: message => set( ( ) => ( {
deleteError: message
} ) ),
setSyncingStatus: syncingStatus => set( ( ) => ( {
syncingStatus
} ) ),
resetSyncToolbar: ( ) => set( ( ) => ( {
currentDeleteCount: 1,
deleteError: null,
deleteQueue: [],
initialNumDeletionsInQueue: 0
} ) ),
startManualSync: ( ) => set( ( ) => {
activateKeepAwake( );
return ( {
syncingStatus: BEGIN_MANUAL_SYNC
} );
} ),
startAutomaticSync: ( ) => set( ( ) => {
activateKeepAwake( );
return ( {
syncingStatus: BEGIN_AUTOMATIC_SYNC
} );
} ),
completeSync: ( ) => set( ( ) => {
deactivateKeepAwake( );
return ( {
currentDeleteCount: 1,
deleteError: null,
deleteQueue: [],
syncingStatus: SYNC_PENDING
} );
} )
} );
export default createSyncObservationsSlice;

View File

@@ -2,10 +2,10 @@ import { MMKV } from "react-native-mmkv";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import createDeleteObservationsSlice from "./createDeleteObservationsSlice";
import createExploreSlice from "./createExploreSlice";
import createLayoutSlice from "./createLayoutSlice";
import createObservationFlowSlice from "./createObservationFlowSlice";
import createSyncObservationsSlice from "./createSyncObservationsSlice";
import createUploadObservationsSlice from "./createUploadObservationsSlice";
const storage = new MMKV();
@@ -25,7 +25,7 @@ const useStore = create( persist(
( ...args ) => {
// Let's make our slices
const slices = [
createDeleteObservationsSlice( ...args ),
createSyncObservationsSlice( ...args ),
createExploreSlice( ...args ),
createObservationFlowSlice( ...args ),
createLayoutSlice( ...args ),

View File

@@ -45,6 +45,10 @@ jest.mock( "sharedHooks/useCurrentUser", ( ) => ( {
const containerID = "announcements-container";
beforeAll( ( ) => {
jest.useFakeTimers( );
} );
describe( "Announcements", () => {
beforeEach( ( ) => {
inaturalistjs.announcements.search.mockReturnValue( Promise.resolve( {

View File

@@ -1,6 +1,5 @@
import { screen } from "@testing-library/react-native";
import MyObservations from "components/MyObservations/MyObservations";
import { INITIAL_STATE } from "components/MyObservations/MyObservationsContainer";
import React from "react";
import DeviceInfo from "react-native-device-info";
import useDeviceOrientation from "sharedHooks/useDeviceOrientation";
@@ -58,22 +57,22 @@ jest.mock( "sharedHooks/useDeviceOrientation", ( ) => ( {
default: jest.fn( () => ( DEVICE_ORIENTATION_PHONE_PORTRAIT ) )
} ) );
const renderMyObservations = layout => renderComponent(
<MyObservations
layout={layout}
observations={mockObservations}
onEndReached={jest.fn( )}
toggleLayout={jest.fn( )}
setShowLoginSheet={jest.fn( )}
/>
);
describe( "MyObservations", () => {
beforeAll( async () => {
jest.useFakeTimers( );
} );
it( "renders an observation", async () => {
renderComponent(
<MyObservations
layout="list"
observations={mockObservations}
onEndReached={jest.fn( )}
toggleLayout={jest.fn( )}
setShowLoginSheet={jest.fn( )}
uploadState={INITIAL_STATE}
/>
);
renderMyObservations( "list" );
const obs = mockObservations[0];
const list = await screen.findByTestId( "MyObservationsAnimatedList" );
@@ -85,17 +84,7 @@ describe( "MyObservations", () => {
} );
it( "renders multiple observations", async () => {
renderComponent(
<MyObservations
layout="list"
observations={mockObservations}
onEndReached={jest.fn( )}
toggleLayout={jest.fn( )}
uploadStatus={{}}
setShowLoginSheet={jest.fn( )}
uploadState={INITIAL_STATE}
/>
);
renderMyObservations( "list" );
// Awaiting the first observation because using await in the forEach errors out
const firstObs = mockObservations[0];
await screen.findByTestId( `MyObservations.obsListItem.${firstObs.uuid}` );
@@ -110,36 +99,17 @@ describe( "MyObservations", () => {
} );
it( "render grid view", ( ) => {
renderComponent(
<MyObservations
layout="grid"
observations={mockObservations}
onEndReached={jest.fn( )}
toggleLayout={jest.fn( )}
setShowLoginSheet={jest.fn( )}
uploadState={INITIAL_STATE}
/>
);
renderMyObservations( "grid" );
mockObservations.forEach( obs => {
expect( screen.getByTestId( `MyObservations.gridItem.${obs.uuid}` ) ).toBeTruthy();
} );
} );
describe( "grid view", ( ) => {
const component = (
<MyObservations
layout="grid"
observations={mockObservations}
onEndReached={jest.fn( )}
toggleLayout={jest.fn( )}
setShowLoginSheet={jest.fn( )}
uploadState={INITIAL_STATE}
/>
);
describe( "portrait orientation", ( ) => {
describe( "on a phone", ( ) => {
it( "should have 2 columns", async ( ) => {
renderComponent( component );
renderMyObservations( "grid" );
const list = screen.getByTestId( "MyObservationsAnimatedList" );
expect( list.props.numColumns ).toEqual( 2 );
} );
@@ -150,7 +120,7 @@ describe( "MyObservations", () => {
} );
it( "should have 4 columns", async ( ) => {
useDeviceOrientation.mockImplementation( ( ) => DEVICE_ORIENTATION_TABLET_PORTRAIT );
renderComponent( component );
renderMyObservations( "grid" );
const list = screen.getByTestId( "MyObservationsAnimatedList" );
expect( list.props.numColumns ).toEqual( 4 );
} );
@@ -160,7 +130,7 @@ describe( "MyObservations", () => {
describe( "on a phone", ( ) => {
it( "should have 2 columns", async ( ) => {
useDeviceOrientation.mockImplementation( ( ) => DEVICE_ORIENTATION_PHONE_LANDSCAPE );
renderComponent( component );
renderMyObservations( "grid" );
const list = screen.getByTestId( "MyObservationsAnimatedList" );
expect( list.props.numColumns ).toEqual( 2 );
} );
@@ -171,7 +141,7 @@ describe( "MyObservations", () => {
} );
it( "should have 6 columns", async ( ) => {
useDeviceOrientation.mockImplementation( ( ) => DEVICE_ORIENTATION_TABLET_LANDSCAPE );
renderComponent( component );
renderMyObservations( "grid" );
const list = screen.getByTestId( "MyObservationsAnimatedList" );
expect( list.props.numColumns ).toEqual( 6 );
} );

View File

@@ -1,7 +1,11 @@
import { screen } from "@testing-library/react-native";
import ToolbarContainer from "components/MyObservations/ToolbarContainer";
import i18next from "i18next";
import React from "react";
import {
AUTOMATIC_SYNC_IN_PROGRESS,
MANUAL_SYNC_IN_PROGRESS,
SYNC_PENDING
} from "stores/createSyncObservationsSlice.ts";
import {
UPLOAD_COMPLETE,
UPLOAD_IN_PROGRESS,
@@ -13,142 +17,156 @@ import { renderComponent } from "tests/helpers/render";
const initialStoreState = useStore.getState( );
const deletionStore = {
currentDeleteCount: 3,
deletions: [{}, {}, {}],
deletionsComplete: false,
deletionsInProgress: false,
error: null
currentDeleteCount: 1,
deleteQueue: [{}],
deleteError: null,
syncingStatus: SYNC_PENDING
};
beforeAll( ( ) => {
jest.useFakeTimers( );
} );
describe( "Toolbar Container", () => {
beforeEach( async () => {
beforeEach( ( ) => {
useStore.setState( initialStoreState, true );
} );
it( "displays a pending upload", async () => {
it( "displays syncing text before beginning uploads when sync button tapped", ( ) => {
useStore.setState( {
numUnuploadedObservations: 1,
uploadStatus: UPLOAD_PENDING
uploadStatus: UPLOAD_PENDING,
syncingStatus: MANUAL_SYNC_IN_PROGRESS
} );
renderComponent( <ToolbarContainer /> );
const statusText = screen.getByText( i18next.t( "Upload-x-observations", { count: 1 } ) );
const statusText = screen.getByText( /Syncing.../ );
expect( statusText ).toBeVisible( );
} );
it( "displays an upload in progress", async () => {
it( "displays a pending upload", ( ) => {
useStore.setState( {
numUnuploadedObservations: 1,
uploadStatus: UPLOAD_PENDING,
syncingStatus: SYNC_PENDING
} );
renderComponent( <ToolbarContainer /> );
const statusText = screen.getByText( /Upload 1 observation/ );
expect( statusText ).toBeVisible( );
} );
it( "displays an upload in progress", ( ) => {
useStore.setState( {
initialNumObservationsInQueue: 1,
numUploadsAttempted: 1,
uploadStatus: UPLOAD_IN_PROGRESS
uploadStatus: UPLOAD_IN_PROGRESS,
syncingStatus: SYNC_PENDING
} );
renderComponent( <ToolbarContainer /> );
const statusText = screen.getByText( i18next.t( "Uploading-x-of-y-observations", {
total: 1,
currentUploadCount: 1
} ) );
const statusText = screen.getByText( /Uploading 1 observation/ );
expect( statusText ).toBeVisible( );
} );
it( "displays a completed upload", async () => {
it( "displays a completed upload", () => {
const numUploadsAttempted = 1;
useStore.setState( {
numUploadsAttempted,
initialNumObservationsInQueue: numUploadsAttempted,
uploadStatus: UPLOAD_COMPLETE
uploadStatus: UPLOAD_COMPLETE,
syncingStatus: SYNC_PENDING,
initialNumObservationsInQueue: numUploadsAttempted
} );
renderComponent( <ToolbarContainer /> );
const statusText = screen.getByText( i18next.t( "X-observations-uploaded", {
count: numUploadsAttempted
} ) );
const statusText = screen.getByText( /1 observation uploaded/ );
expect( statusText ).toBeVisible( );
} );
it( "displays an upload error", async () => {
it( "displays an upload error", () => {
const multiError = "Couldn't complete upload";
useStore.setState( {
multiError
multiError,
syncingStatus: SYNC_PENDING
} );
renderComponent( <ToolbarContainer /> );
expect( screen.getByText( multiError ) ).toBeVisible( );
} );
it( "displays multiple pending uploads", async () => {
it( "displays multiple pending uploads", () => {
useStore.setState( {
numUnuploadedObservations: 4,
uploadStatus: UPLOAD_PENDING
uploadStatus: UPLOAD_PENDING,
syncingStatus: SYNC_PENDING
} );
renderComponent( <ToolbarContainer /> );
const statusText = screen.getByText( i18next.t( "Upload-x-observations", { count: 4 } ) );
const statusText = screen.getByText( /Upload 4 observations/ );
expect( statusText ).toBeVisible( );
} );
it( "displays multiple uploads in progress", async () => {
it( "displays multiple uploads in progress", () => {
useStore.setState( {
uploadStatus: UPLOAD_IN_PROGRESS,
initialNumObservationsInQueue: 5,
numUploadsAttempted: 2
numUploadsAttempted: 2,
syncingStatus: SYNC_PENDING,
initialNumObservationsInQueue: 5
} );
renderComponent( <ToolbarContainer /> );
const statusText = screen.getByText( i18next.t( "Uploading-x-of-y-observations", {
total: 5,
currentUploadCount: 2
} ) );
const statusText = screen.getByText( /Uploading 2 of 5 observations/ );
expect( statusText ).toBeVisible( );
} );
it( "displays multiple completed uploads", async () => {
it( "displays multiple completed uploads", () => {
const numUploadsAttempted = 7;
useStore.setState( {
numUploadsAttempted,
initialNumObservationsInQueue: numUploadsAttempted,
uploadStatus: UPLOAD_COMPLETE
uploadStatus: UPLOAD_COMPLETE,
syncingStatus: SYNC_PENDING,
initialNumObservationsInQueue: numUploadsAttempted
} );
renderComponent( <ToolbarContainer /> );
const statusText = screen.getByText( i18next.t( "X-observations-uploaded", {
count: numUploadsAttempted
} ) );
const statusText = screen.getByText( /7 observations uploaded/ );
expect( statusText ).toBeVisible( );
} );
it( "displays deletions in progress", async () => {
// 20240611 amanda - removing this test for now, since I believe the new intended UI
// is that the user will only ever see "Syncing..." followed by
// "1 observation deleted" UI after deleting a local observation. feel free to reinstate this
// test if I'm misunderstanding the UI
// it( "displays deletions in progress", async () => {
// useStore.setState( {
// ...deletionStore,
// syncingStatus: HANDLING_LOCAL_DELETIONS
// } );
// renderComponent( <ToolbarContainer /> );
// const statusText = screen.getByText( /Deleting 1 of 1 observation/ );
// expect( statusText ).toBeVisible( );
// } );
it( "displays deletions completed", () => {
useStore.setState( {
...deletionStore,
deletionsInProgress: true,
currentDeleteCount: 2
currentDeleteCount: 1,
deleteQueue: [{}],
initialNumDeletionsInQueue: 1
} );
renderComponent( <ToolbarContainer /> );
const statusText = screen.getByText( i18next.t( "Deleting-x-of-y-observations", {
total: 3,
currentDeleteCount: 2
} ) );
const statusText = screen.getByText( /1 observation deleted/ );
expect( statusText ).toBeVisible( );
} );
it( "displays deletions completed", async () => {
useStore.setState( {
...deletionStore,
deletionsComplete: true
} );
renderComponent( <ToolbarContainer /> );
const statusText = screen.getByText( i18next.t( "X-observations-deleted", {
count: 3
} ) );
expect( statusText ).toBeVisible( );
} );
it( "displays deletion error", async () => {
it( "displays deletion error", () => {
const deleteError = "Unknown problem deleting observations";
useStore.setState( {
...deletionStore,
deleteError
deleteError,
syncingStatus: AUTOMATIC_SYNC_IN_PROGRESS
} );
renderComponent( <ToolbarContainer /> );

View File

@@ -1,102 +0,0 @@
import { renderHook, waitFor } from "@testing-library/react-native";
import useDeleteObservations from "components/MyObservations/hooks/useDeleteObservations.ts";
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
import useStore from "stores/useStore";
import factory from "tests/factory";
import faker from "tests/helpers/faker";
const initialStoreState = useStore.getState( );
const deletionStore = {
currentDeleteCount: 3,
deletions: [{}, {}, {}],
deletionsComplete: false,
deletionsInProgress: false,
error: null
};
const mockMutate = jest.fn();
jest.mock( "sharedHooks/useAuthenticatedMutation", ( ) => ( {
__esModule: true,
default: ( ) => ( {
mutate: mockMutate
} )
} ) );
const syncedObservations = [
factory( "LocalObservation", {
_deleted_at: faker.date.past( ),
_synced_at: faker.date.past( )
} )
];
const unsyncedObservations = [
factory( "LocalObservation", {
_deleted_at: faker.date.past( ),
_synced_at: null
} )
];
const getLocalObservation = uuid => global.realm
.objectForPrimaryKey( "Observation", uuid );
const createObservations = ( observations, comment ) => {
safeRealmWrite(
global.realm,
( ) => {
observations.forEach( observation => {
global.realm.create( "Observation", observation );
} );
},
comment
);
};
describe( "handle deletions", ( ) => {
beforeAll( async () => {
useStore.setState( initialStoreState, true );
} );
it( "should not make deletion API call for unsynced observations", async ( ) => {
createObservations(
unsyncedObservations,
"write unsyncedObservations, useDeleteObservations test"
);
const unsyncedObservation = getLocalObservation(
unsyncedObservations[0].uuid
);
expect( unsyncedObservation._synced_at ).toBeNull( );
renderHook( ( ) => useDeleteObservations( true, ( ) => null ) );
useStore.setState( {
...deletionStore,
deletions: unsyncedObservations,
currentDeleteCount: 1
} );
await waitFor( ( ) => {
expect( mockMutate ).not.toHaveBeenCalled( );
} );
} );
it( "should make deletion API call for previously synced observations", async ( ) => {
createObservations(
syncedObservations,
"write syncedObservations, useDeleteObservations test"
);
const syncedObservation = getLocalObservation( syncedObservations[0].uuid );
expect( syncedObservation._synced_at ).not.toBeNull( );
renderHook( ( ) => useDeleteObservations( true, ( ) => null ) );
useStore.setState( {
...deletionStore,
deletions: syncedObservations,
currentDeleteCount: 1
} );
await waitFor( ( ) => {
expect( mockMutate )
.toHaveBeenCalledWith( { uuid: syncedObservations[0].uuid } );
} );
} );
} );

View File

@@ -0,0 +1,240 @@
import { renderHook, waitFor } from "@testing-library/react-native";
import useSyncObservations from "components/MyObservations/hooks/useSyncObservations.ts";
import inatjs from "inaturalistjs";
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
import {
BEGIN_AUTOMATIC_SYNC,
BEGIN_MANUAL_SYNC
} from "stores/createSyncObservationsSlice.ts";
import useStore from "stores/useStore";
import factory, { makeResponse } from "tests/factory";
import faker from "tests/helpers/faker";
import setupUniqueRealm from "tests/helpers/uniqueRealm";
const initialStoreState = useStore.getState( );
const currentUserId = 1;
const syncedObservations = [
factory( "LocalObservation", {
_deleted_at: faker.date.past( ),
_synced_at: faker.date.past( )
} )
];
const unsyncedObservations = [
factory( "LocalObservation", {
_deleted_at: faker.date.past( ),
_synced_at: null
} ),
factory( "LocalObservation", {
_deleted_at: faker.date.past( ),
_synced_at: null
} )
];
const obsToDelete = unsyncedObservations[0];
const syncingStore = {
currentDeleteCount: 1,
deleteQueue: [obsToDelete.uuid],
syncingStatus: BEGIN_AUTOMATIC_SYNC,
deleteError: null,
deletionsCompletedAt: null
};
const mockDeletedIds = [
faker.number.int( )
];
const mockRemoteObservation = factory( "RemoteObservation", {
taxon: factory.states( "genus" )( "RemoteTaxon" )
} );
const mockMutate = jest.fn();
jest.mock( "sharedHooks/useAuthenticatedMutation", ( ) => ( {
__esModule: true,
default: ( ) => ( {
mutate: mockMutate
} )
} ) );
const mockUpload = jest.fn( );
jest.mock( "components/MyObservations/hooks/useUploadObservations", ( ) => ( {
__esModule: true,
default: ( ) => ( {
uploadObservations: mockUpload
} )
} ) );
// 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],
useQuery: ( ) => []
}
};
} );
beforeAll( uniqueRealmBeforeAll );
afterAll( uniqueRealmAfterAll );
// /UNIQUE REALM SETUP
const getLocalObservation = uuid => global.mockRealms[__filename]
.objectForPrimaryKey( "Observation", uuid );
const createObservations = ( observations, comment ) => {
const realm = global.mockRealms[__filename];
safeRealmWrite(
realm,
( ) => {
observations.forEach( observation => {
realm.create( "Observation", observation );
} );
},
comment
);
};
beforeAll( async () => {
useStore.setState( initialStoreState, true );
} );
describe( "automatic sync while user is logged out", ( ) => {
beforeEach( () => {
const realm = global.mockRealms[__filename];
safeRealmWrite( realm, ( ) => {
realm.create( "Observation", obsToDelete );
}, "write Observation to delete, useSyncObservations.ts" );
} );
it( "should not fetch remote observations or deletions when user is logged out", async ( ) => {
useStore.setState( {
...syncingStore
} );
renderHook( ( ) => useSyncObservations( null ) );
await waitFor( ( ) => {
expect( inatjs.observations.deleted ).not.toHaveBeenCalled( );
} );
await waitFor( ( ) => {
expect( inatjs.observations.search ).not.toHaveBeenCalled( );
} );
} );
it( "should delete local observations without making a server call", async ( ) => {
const realm = global.mockRealms[__filename];
const obsForDeletion = realm.objectForPrimaryKey( "Observation", obsToDelete.uuid );
expect( obsForDeletion ).toBeTruthy( );
useStore.setState( {
...syncingStore
} );
renderHook( ( ) => useSyncObservations( null ) );
const deletedObs = realm.objectForPrimaryKey( "Observation", obsToDelete.uuid );
await waitFor( ( ) => {
expect( deletedObs ).toBeFalsy( );
} );
expect( mockMutate ).not.toHaveBeenCalled( );
} );
} );
describe( "automatic sync while user is logged in", ( ) => {
describe( "fetch remote deletions", ( ) => {
inatjs.observations.deleted.mockResolvedValue( makeResponse( mockDeletedIds ) );
it( "should fetch deleted observations from server", async ( ) => {
useStore.setState( {
...syncingStore
} );
renderHook( ( ) => useSyncObservations( currentUserId ) );
await waitFor( ( ) => {
expect( inatjs.observations.deleted ).toHaveBeenCalled( );
} );
} );
} );
describe( "handle local deletions", ( ) => {
it( "should not make deletion API call for unsynced observations", async ( ) => {
createObservations(
unsyncedObservations,
"write unsyncedObservations, useSyncObservations test"
);
const unsyncedObservation = getLocalObservation(
unsyncedObservations[0].uuid
);
expect( unsyncedObservation._synced_at ).toBeNull( );
useStore.setState( {
...syncingStore,
deleteQueue: [unsyncedObservations[0].uuid]
} );
renderHook( ( ) => useSyncObservations( currentUserId ) );
await waitFor( ( ) => {
expect( mockMutate ).not.toHaveBeenCalled( );
} );
} );
it( "should make deletion API call for previously synced observations", async ( ) => {
createObservations(
syncedObservations,
"write syncedObservations, useSyncObservations test"
);
const syncedObservation = getLocalObservation( syncedObservations[0].uuid );
expect( syncedObservation._synced_at ).not.toBeNull( );
useStore.setState( {
...syncingStore,
deleteQueue: [syncedObservations[0].uuid]
} );
renderHook( ( ) => useSyncObservations( currentUserId ) );
await waitFor( ( ) => {
expect( mockMutate )
.toHaveBeenCalledWith( { uuid: syncedObservations[0].uuid } );
} );
} );
} );
describe( "fetch remote observations", ( ) => {
inatjs.observations.search.mockResolvedValue( makeResponse( [mockRemoteObservation] ) );
it( "should fetch remote observations from server when user is logged in", async ( ) => {
useStore.setState( {
...syncingStore
} );
renderHook( ( ) => useSyncObservations( currentUserId ) );
await waitFor( ( ) => {
expect( inatjs.observations.search ).toHaveBeenCalled( );
} );
} );
} );
it( "should not create upload queue", async ( ) => {
useStore.setState( {
...syncingStore
} );
renderHook( ( ) => useSyncObservations( currentUserId, mockUpload ) );
await waitFor( ( ) => {
expect( mockUpload ).not.toHaveBeenCalled( );
} );
} );
} );
describe( "manual sync while user is logged in", ( ) => {
it( "should begin uploading unsynced observations", async ( ) => {
useStore.setState( {
...syncingStore,
syncingStatus: BEGIN_MANUAL_SYNC
} );
renderHook( ( ) => useSyncObservations( currentUserId, mockUpload ) );
await waitFor( ( ) => {
expect( mockUpload ).toHaveBeenCalled( );
} );
} );
} );