diff --git a/.eslintrc.js b/.eslintrc.js index 6d33aa3d9..bc0be437b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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: { diff --git a/package-lock.json b/package-lock.json index 2d2660e30..a2514b7d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/components/MyObservations/MyObservations.js b/src/components/MyObservations/MyObservations.js index 0d5d943b8..240a2db98 100644 --- a/src/components/MyObservations/MyObservations.js +++ b/src/components/MyObservations/MyObservations.js @@ -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} /> )} diff --git a/src/components/MyObservations/MyObservationsContainer.js b/src/components/MyObservations/MyObservationsContainer.js index d561d8eeb..2f8c9c325 100644 --- a/src/components/MyObservations/MyObservationsContainer.js +++ b/src/components/MyObservations/MyObservationsContainer.js @@ -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} /> ); diff --git a/src/components/MyObservations/MyObservationsHeader.js b/src/components/MyObservations/MyObservationsHeader.js index 27977fd96..91abe9933 100644 --- a/src/components/MyObservations/MyObservationsHeader.js +++ b/src/components/MyObservations/MyObservationsHeader.js @@ -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 = ( { )} diff --git a/src/components/MyObservations/ToolbarContainer.js b/src/components/MyObservations/ToolbarContainer.js index 05ab784f0..441c641eb 100644 --- a/src/components/MyObservations/ToolbarContainer.js +++ b/src/components/MyObservations/ToolbarContainer.js @@ -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} diff --git a/src/components/MyObservations/helpers/filterLocalObservationsToDelete.ts b/src/components/MyObservations/helpers/filterLocalObservationsToDelete.ts deleted file mode 100644 index 557515990..000000000 --- a/src/components/MyObservations/helpers/filterLocalObservationsToDelete.ts +++ /dev/null @@ -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( ) ); -}; diff --git a/src/components/MyObservations/helpers/syncRemoteObservations.ts b/src/components/MyObservations/helpers/syncRemoteObservations.ts new file mode 100644 index 000000000..2b8839a50 --- /dev/null +++ b/src/components/MyObservations/helpers/syncRemoteObservations.ts @@ -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 ); +}; diff --git a/src/components/MyObservations/hooks/useDeleteObservations.ts b/src/components/MyObservations/hooks/useDeleteObservations.ts deleted file mode 100644 index 02b08cb8a..000000000 --- a/src/components/MyObservations/hooks/useDeleteObservations.ts +++ /dev/null @@ -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; diff --git a/src/components/MyObservations/hooks/useSyncObservations.ts b/src/components/MyObservations/hooks/useSyncObservations.ts new file mode 100644 index 000000000..3304d9c71 --- /dev/null +++ b/src/components/MyObservations/hooks/useSyncObservations.ts @@ -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; diff --git a/src/components/MyObservations/hooks/useUploadObservations.ts b/src/components/MyObservations/hooks/useUploadObservations.ts index 92fa45583..6d35e0abb 100644 --- a/src/components/MyObservations/hooks/useUploadObservations.ts +++ b/src/components/MyObservations/hooks/useUploadObservations.ts @@ -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 + }; }; diff --git a/src/components/ObsEdit/Sheets/DeleteObservationSheet.js b/src/components/ObsEdit/Sheets/DeleteObservationSheet.js index bedabde0f..fe12471c8 100644 --- a/src/components/ObsEdit/Sheets/DeleteObservationSheet.js +++ b/src/components/ObsEdit/Sheets/DeleteObservationSheet.js @@ -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 ] ); diff --git a/src/stores/createDeleteObservationsSlice.ts b/src/stores/createDeleteObservationsSlice.ts deleted file mode 100644 index cffda0eb1..000000000 --- a/src/stores/createDeleteObservationsSlice.ts +++ /dev/null @@ -1,37 +0,0 @@ -const DEFAULT_STATE = { - currentDeleteCount: 1, - deletions: [], - deletionsComplete: false, - deletionsCompletedAt: null, - deletionsInProgress: false, - deleteError: null -}; - -interface DeleteObservationsSlice { - currentDeleteCount: number, - deletions: Array, - deletionsComplete: boolean, - deletionsCompletedAt: Date, - deletionsInProgress: boolean, - deleteError: string | null -} - -const createDeleteObservationsSlice: StateCreator = 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; diff --git a/src/stores/createSyncObservationsSlice.ts b/src/stores/createSyncObservationsSlice.ts new file mode 100644 index 000000000..bc66bd83b --- /dev/null +++ b/src/stores/createSyncObservationsSlice.ts @@ -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, + deletionsCompletedAt: Date, + initialNumDeletionsInQueue: number, + syncingStatus: typeof SYNC_PENDING + | typeof AUTOMATIC_SYNC_IN_PROGRESS + | typeof MANUAL_SYNC_IN_PROGRESS +} + +const createSyncObservationsSlice: StateCreator = 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; diff --git a/src/stores/useStore.js b/src/stores/useStore.js index a23638718..ffdf01777 100644 --- a/src/stores/useStore.js +++ b/src/stores/useStore.js @@ -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 ), diff --git a/tests/unit/components/MyObservations/Announcements.test.js b/tests/unit/components/MyObservations/Announcements.test.js index efef81d7c..6292ac223 100644 --- a/tests/unit/components/MyObservations/Announcements.test.js +++ b/tests/unit/components/MyObservations/Announcements.test.js @@ -45,6 +45,10 @@ jest.mock( "sharedHooks/useCurrentUser", ( ) => ( { const containerID = "announcements-container"; +beforeAll( ( ) => { + jest.useFakeTimers( ); +} ); + describe( "Announcements", () => { beforeEach( ( ) => { inaturalistjs.announcements.search.mockReturnValue( Promise.resolve( { diff --git a/tests/unit/components/MyObservations/MyObservations.test.js b/tests/unit/components/MyObservations/MyObservations.test.js index 186f26c58..a30b8dd43 100644 --- a/tests/unit/components/MyObservations/MyObservations.test.js +++ b/tests/unit/components/MyObservations/MyObservations.test.js @@ -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( + +); + describe( "MyObservations", () => { beforeAll( async () => { jest.useFakeTimers( ); } ); - it( "renders an observation", async () => { - renderComponent( - - ); + renderMyObservations( "list" ); const obs = mockObservations[0]; const list = await screen.findByTestId( "MyObservationsAnimatedList" ); @@ -85,17 +84,7 @@ describe( "MyObservations", () => { } ); it( "renders multiple observations", async () => { - renderComponent( - - ); + 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( - - ); + renderMyObservations( "grid" ); mockObservations.forEach( obs => { expect( screen.getByTestId( `MyObservations.gridItem.${obs.uuid}` ) ).toBeTruthy(); } ); } ); describe( "grid view", ( ) => { - const component = ( - - ); 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 ); } ); diff --git a/tests/unit/components/MyObservations/ToolbarContainer.test.js b/tests/unit/components/MyObservations/ToolbarContainer.test.js index 77970e30d..58bfd7133 100644 --- a/tests/unit/components/MyObservations/ToolbarContainer.test.js +++ b/tests/unit/components/MyObservations/ToolbarContainer.test.js @@ -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( ); - 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( ); + + 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( ); - 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( ); - 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( ); 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( ); - 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( ); - 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( ); - 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( ); + + // 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( ); - 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( ); - - 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( ); diff --git a/tests/unit/components/MyObservations/useDeleteObservations.test.js b/tests/unit/components/MyObservations/useDeleteObservations.test.js deleted file mode 100644 index b9f13abcd..000000000 --- a/tests/unit/components/MyObservations/useDeleteObservations.test.js +++ /dev/null @@ -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 } ); - } ); - } ); -} ); diff --git a/tests/unit/components/MyObservations/useSyncObservations.test.js b/tests/unit/components/MyObservations/useSyncObservations.test.js new file mode 100644 index 000000000..9184e080d --- /dev/null +++ b/tests/unit/components/MyObservations/useSyncObservations.test.js @@ -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( ); + } ); + } ); +} );