mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2025-12-23 22:18:36 -05:00
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:
committed by
GitHub
parent
76824636ef
commit
771e5c810b
@@ -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
1
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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( ) );
|
||||
};
|
||||
@@ -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 );
|
||||
};
|
||||
@@ -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;
|
||||
185
src/components/MyObservations/hooks/useSyncObservations.ts
Normal file
185
src/components/MyObservations/hooks/useSyncObservations.ts
Normal 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;
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
] );
|
||||
|
||||
@@ -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;
|
||||
95
src/stores/createSyncObservationsSlice.ts
Normal file
95
src/stores/createSyncObservationsSlice.ts
Normal 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;
|
||||
@@ -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 ),
|
||||
|
||||
@@ -45,6 +45,10 @@ jest.mock( "sharedHooks/useCurrentUser", ( ) => ( {
|
||||
|
||||
const containerID = "announcements-container";
|
||||
|
||||
beforeAll( ( ) => {
|
||||
jest.useFakeTimers( );
|
||||
} );
|
||||
|
||||
describe( "Announcements", () => {
|
||||
beforeEach( ( ) => {
|
||||
inaturalistjs.announcements.search.mockReturnValue( Promise.resolve( {
|
||||
|
||||
@@ -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 );
|
||||
} );
|
||||
|
||||
@@ -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 /> );
|
||||
|
||||
|
||||
@@ -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 } );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
240
tests/unit/components/MyObservations/useSyncObservations.test.js
Normal file
240
tests/unit/components/MyObservations/useSyncObservations.test.js
Normal 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( );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
Reference in New Issue
Block a user