diff --git a/api/package.json b/api/package.json index 791170272..8c672a302 100644 --- a/api/package.json +++ b/api/package.json @@ -13,7 +13,7 @@ "dev": "nodemon --exec 'tsx' src/server --watch ../sharedAPI --watch .", "start": "tsx src/server", "export-map": "tsx src/map-exporter", - "export-geojson": "ts-node src/geojson-exporter", + "export-geojson": "tsx src/geojson-exporter", "test": "NODE_OPTIONS='--import tsx' mocha \"./src/tests/**/*.test.ts\" --timeout 10000", "lint": "eslint", "lint-fix": "eslint --fix" diff --git a/api/src/utils/batch/templates/monitoring_biocontrol_release_terrestrial_plant.ts b/api/src/utils/batch/templates/monitoring_biocontrol_release_terrestrial_plant.ts index fdd64a731..cd3b9f108 100644 --- a/api/src/utils/batch/templates/monitoring_biocontrol_release_terrestrial_plant.ts +++ b/api/src/utils/batch/templates/monitoring_biocontrol_release_terrestrial_plant.ts @@ -70,7 +70,7 @@ MonitoringBiocontrolReleaseTerrestrialPlant.columns = [ .isRequired() .build(), - new TemplateColumnBuilder('Monitoring - Linked Treatment ID', 'text', 'form_data.activity_type_data.linked_id') + new TemplateColumnBuilder('Monitoring - Linked Treatment ID', 'linked_id', 'form_data.activity_type_data.linked_id') .isRequired() .build(), new TemplateColumnBuilder( diff --git a/api/src/utils/batch/templates/monitoring_chemical_treatment_temp.ts b/api/src/utils/batch/templates/monitoring_chemical_treatment_temp.ts index d44836463..dec39fa6a 100644 --- a/api/src/utils/batch/templates/monitoring_chemical_treatment_temp.ts +++ b/api/src/utils/batch/templates/monitoring_chemical_treatment_temp.ts @@ -31,7 +31,7 @@ MonitoringChemicalTemp.columns = [ new TemplateColumnBuilder( 'Monitoring - Linked Treatment ID', - 'text', + 'linked_id', 'form_data.activity_type_data.linked_id' ).build(), diff --git a/api/src/utils/batch/templates/monitoring_mechanical_treatment.ts b/api/src/utils/batch/templates/monitoring_mechanical_treatment.ts index a525b2829..020ea337d 100644 --- a/api/src/utils/batch/templates/monitoring_mechanical_treatment.ts +++ b/api/src/utils/batch/templates/monitoring_mechanical_treatment.ts @@ -22,7 +22,7 @@ MonitoringMechanical.columns = [ new TemplateColumnBuilder( 'Monitoring - Linked Treatment ID', - 'text', + 'linked_id', 'form_data.activity_type_data.linked_id' ).build(), diff --git a/api/src/utils/batch/validation/validation.ts b/api/src/utils/batch/validation/validation.ts index ae09cdfb3..9d3756199 100644 --- a/api/src/utils/batch/validation/validation.ts +++ b/api/src/utils/batch/validation/validation.ts @@ -1,6 +1,6 @@ import slugify from 'slugify'; import moment from 'moment'; -import { ActivityLetter, lookupAreaLimit } from 'sharedAPI'; +import { ActivityLetter, ActivitySubtype, lookupAreaLimit } from 'sharedAPI'; import circle from '@turf/circle'; import booleanIntersects from '@turf/boolean-intersects'; import { @@ -197,48 +197,48 @@ const _handleBooleanCell = (data: string, result: CellValidationResult) => { * @param activityLetter The Letter corresponsing to the type of entry being uploaded * @returns Regex Pattern matches ShortID */ -const validateShortID = (shortId, activityLetter) => { +const validateShortID = (shortId: string, activityLetter: string) => { const shortIdPattern = RegExp(`^[0-9]{2}${activityLetter}[0-9A-Z]{8}$`); return shortIdPattern.test(shortId); }; /** - * @desc Validation Handler for batch records with type Activity_Monitoring_ChemicalTerrestrialAquaticPlant + * @desc Customization for Linked ID Validation + * @property {string[]} expectedRecordTypes Recordtypes that must match to be properly linked + * @property {string[]} shortIdActivityLetters unique three letter code in Activity Short ID identifying it as a record type. e.g. 'PAC', 'PTM', etc + */ +interface LinkedIdOptions { + expectedRecordTypes: string[]; + shortIdActivityLetters: string[]; +} +/** + * @desc Validation Handler for matching Records with linked_id * Validates fields in form to ensure data properly links with an existing record. - * @param data - * @param result */ -const _handleActivity_Monitoring_ChemicalTerrestrialAquaticPlant = async ( +const _handleGetLinkedId = async ( shortId: string, result: CellValidationResult, - row: Record + row: Record, + options: LinkedIdOptions ) => { try { - const isValidShortID = - validateShortID(shortId, ActivityLetter.Activity_Treatment_ChemicalPlantAquatic) || - validateShortID(shortId, 'PTC'); + const isValidShortID = options.shortIdActivityLetters.some((letters) => validateShortID(shortId, letters)); if (!isValidShortID) { result.validationMessages.push(invalidShortID); return; } - const expectedRecordTypes = [ - 'Activity_Treatment_ChemicalPlantAquatic', - 'Activity_Treatment_ChemicalPlantTerrestrial' - ]; - const batchUploadInvasivePlantRow = 'Monitoring - Terrestrial Invasive Plant'; - const batchUploadTerrestrialPlantRow = 'Monitoring - Aquatic Invasive Plant'; const linkedRecord = await getRecordFromShort(shortId); if (!linkedRecord) { result.validationMessages.push(invalidLongID(shortId)); return; } - const isItTheRightRecordType = expectedRecordTypes.includes(linkedRecord['activity_subtype']); + const isItTheRightRecordType = options.expectedRecordTypes.includes(linkedRecord['activity_subtype']); const doTheSpeciesMatch = - linkedRecord['species_treated']?.includes(row.data[batchUploadInvasivePlantRow]) || - linkedRecord['species_treated']?.includes(row.data[batchUploadTerrestrialPlantRow]); + linkedRecord['species_treated']?.includes(row.data['Monitoring - Terrestrial Invasive Plant']) || + linkedRecord['species_treated']?.includes(row.data['Monitoring - Aquatic Invasive Plant']); const thisGeoJSON: any = row.data['WKT']; - const isValidGeoJSON: boolean = thisGeoJSON || false; - const linkedGeoJSON: any = JSON.parse(linkedRecord['sample']) || false; + const isValidGeoJSON: boolean = thisGeoJSON ?? false; + const linkedGeoJSON: any = JSON.parse(linkedRecord['sample']) ?? false; const doTheyOverlap = isValidGeoJSON && linkedGeoJSON && booleanIntersects(thisGeoJSON.parsedValue?.geojson, linkedGeoJSON); if (!doTheSpeciesMatch) { @@ -257,11 +257,11 @@ const _handleActivity_Monitoring_ChemicalTerrestrialAquaticPlant = async ( result.validationMessages.push(invalidWKT); } if (linkedRecord) { - result.parsedValue = linkedRecord['activity_id'] || ''; + result.parsedValue = linkedRecord['activity_id'] ?? ''; } } catch (e) { defaultLog.error({ - message: '[handleActivity_Monitoring_ChemicalTerrestrialAquaticPlant]', + message: '[_handleGetLinkedId]', error: e }); } @@ -303,8 +303,35 @@ async function _validateCell( } const thisRecordType = template.subtype; switch (thisRecordType) { - case 'Activity_Monitoring_ChemicalTerrestrialAquaticPlant': - await _handleActivity_Monitoring_ChemicalTerrestrialAquaticPlant(data, result, row); + case ActivitySubtype.Monitoring_ChemicalTerrestrialAquaticPlant: + await _handleGetLinkedId(data, result, row, { + shortIdActivityLetters: [ + ActivityLetter[ActivitySubtype.Treatment_ChemicalPlantAquatic], + ActivityLetter[ActivitySubtype.Treatment_ChemicalPlant] + ], + expectedRecordTypes: [ + ActivitySubtype.Treatment_ChemicalPlantAquatic, + ActivitySubtype.Treatment_ChemicalPlant + ] + }); + break; + case ActivitySubtype.Monitoring_MechanicalTerrestrialAquaticPlant: + await _handleGetLinkedId(data, result, row, { + shortIdActivityLetters: [ + ActivityLetter[ActivitySubtype.Treatment_MechanicalPlantAquatic], + ActivityLetter[ActivitySubtype.Treatment_MechanicalPlant] + ], + expectedRecordTypes: [ + ActivitySubtype.Treatment_MechanicalPlantAquatic, + ActivitySubtype.Treatment_MechanicalPlant + ] + }); + break; + case ActivitySubtype.Monitoring_BiologicalTerrestrialPlant: + await _handleGetLinkedId(data, result, row, { + shortIdActivityLetters: [ActivityLetter[ActivitySubtype.Treatment_BiologicalPlant]], + expectedRecordTypes: [ActivitySubtype.Treatment_BiologicalPlant] + }); break; default: break; diff --git a/app/src/UI/App.tsx b/app/src/UI/App.tsx index 95dc9fc83..89d28a352 100644 --- a/app/src/UI/App.tsx +++ b/app/src/UI/App.tsx @@ -29,7 +29,6 @@ import { OfflineDataSyncDialog } from 'UI/OfflineDataSync/OfflineDataSyncDialog' import Spinner from 'UI/Spinner/Spinner'; import { WebOnly } from 'UI/Predicates/WebOnly'; import { useSelector } from 'utils/use_selector'; -import { MobileBetaAccessMessage } from 'UI/Overlay/MobileBetaAccess/MobileBetaAccessMessage'; import AlertsContainer from './AlertsContainer/AlertsContainer'; import UserInputModalController from './UserInputModals/UserInputModalController'; import { MOBILE, PLATFORM, Platform } from 'state/build-time-config'; diff --git a/app/src/UI/Header/Header.tsx b/app/src/UI/Header/Header.tsx index db5f7a9f4..096616fbe 100644 --- a/app/src/UI/Header/Header.tsx +++ b/app/src/UI/Header/Header.tsx @@ -377,9 +377,9 @@ const LoginOrOutMemo = React.memo(() => { const NetworkStateControl: React.FC = () => { const handleNetworkStateChange = () => { - dispatch(connected ? NetworkActions.offline() : NetworkActions.online()); + dispatch(connected ? NetworkActions.setAdministrativeStatus(false) : NetworkActions.manualReconnect()); }; - const { connected } = useSelector((state) => state.Network); + const connected = useSelector((state) => state.Network.connected); const dispatch = useDispatch(); return (
diff --git a/app/src/UI/OfflineDataSync/OfflineDataSyncTable.tsx b/app/src/UI/OfflineDataSync/OfflineDataSyncTable.tsx index bee8f29d5..47f078de3 100644 --- a/app/src/UI/OfflineDataSync/OfflineDataSyncTable.tsx +++ b/app/src/UI/OfflineDataSync/OfflineDataSyncTable.tsx @@ -3,7 +3,11 @@ import { Button, IconButton, LinearProgress } from '@mui/material'; import { OfflineActivityRecord, selectOfflineActivity } from 'state/reducers/offlineActivity'; import { useSelector } from 'utils/use_selector'; import { useDispatch } from 'react-redux'; -import { ACTIVITY_OFFLINE_DELETE_ITEM, ACTIVITY_RUN_OFFLINE_SYNC } from 'state/actions'; +import { + ACTIVITY_OFFLINE_DELETE_ITEM, + ACTIVITY_OFFLINE_SYNC_DIALOG_SET_STATE, + ACTIVITY_RUN_OFFLINE_SYNC +} from 'state/actions'; import Delete from '@mui/icons-material/Delete'; import './OfflineDataSync.css'; import moment from 'moment'; @@ -16,7 +20,6 @@ export const OfflineDataSyncTable = () => { const { working, serializedActivities } = useSelector(selectOfflineActivity); const { authenticated, workingOffline } = useSelector((state) => state.Auth); const connected = useSelector((state) => state.Network.connected); - const [syncDisabled, setSyncDisabled] = useState(false); const history = useHistory(); @@ -63,8 +66,8 @@ export const OfflineDataSyncTable = () => { disabled={!(workingOffline || authenticated)} color="primary" onClick={() => { - dispatch(Activity.getLocal(key)); history.push(`/Records/Activity:${key}/form`); + dispatch({ type: ACTIVITY_OFFLINE_SYNC_DIALOG_SET_STATE, payload: { open: false } }); }} > diff --git a/app/src/UI/Overlay/MobileBetaAccess/MobileBetaAccessMessage.css b/app/src/UI/Overlay/MobileBetaAccess/MobileBetaAccessMessage.css deleted file mode 100644 index 46fc7f487..000000000 --- a/app/src/UI/Overlay/MobileBetaAccess/MobileBetaAccessMessage.css +++ /dev/null @@ -1,8 +0,0 @@ -.mobile-beta-access-modal { - position: absolute; - inset: calc(var(--header-bar-height) + 20px) 20px calc(var(--footer-bar-height) + 20px); - border: 2px solid grey; - padding: 1rem; - z-index: 10000; - background-color: white; -} diff --git a/app/src/UI/Overlay/MobileBetaAccess/MobileBetaAccessMessage.tsx b/app/src/UI/Overlay/MobileBetaAccess/MobileBetaAccessMessage.tsx deleted file mode 100644 index 5e6fae7ef..000000000 --- a/app/src/UI/Overlay/MobileBetaAccess/MobileBetaAccessMessage.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { Box, Modal, Typography } from '@mui/material'; - -import './MobileBetaAccessMessage.css'; -import { useSelector } from 'utils/use_selector'; -import { selectAuth } from 'state/reducers/auth'; - -export const MobileBetaAccessMessage = () => { - const { authenticated, idir_user_guid } = useSelector(selectAuth); - - const [show, setShow] = useState(false); - - useEffect(() => { - if (!authenticated) { - setShow(false); - return; - } - - if (idir_user_guid && idir_user_guid.length > 0) { - setShow(false); - return; - } - - setShow(true); - }, [authenticated, idir_user_guid]); - - return ( - {}}> - - - Mobile Application Access is in BETA - - - Thank you for showing interest in the InvasivesBC application. This release of the mobile application is - available for testing by IDIR account holders. A wider release is planned shortly. - - - A fully functional web application is available at{' '} - https://invasivesbc.gov.bc.ca/ - - - - ); -}; diff --git a/app/src/UI/Overlay/Records/Records.tsx b/app/src/UI/Overlay/Records/Records.tsx index 457501bca..7a2b318ad 100644 --- a/app/src/UI/Overlay/Records/Records.tsx +++ b/app/src/UI/Overlay/Records/Records.tsx @@ -1,9 +1,7 @@ import { MouseEvent, useEffect, useState } from 'react'; - import { Button } from '@mui/material'; import './Records.css'; import { OverlayHeader } from '../OverlayHeader'; -import Spinner from 'UI/Spinner/Spinner'; import { useHistory } from 'react-router-dom'; import { useDispatch, useSelector } from 'utils/use_selector'; import UserSettings from 'state/actions/userSettings/UserSettings'; @@ -16,15 +14,9 @@ import filterRecordsetsByNetworkState from 'utils/filterRecordsetsByNetworkState export const Records = () => { const DEFAULT_RECORD_TYPES = ['All InvasivesBC Activities', 'All IAPP Records', 'My Drafts']; - const activitiesGeoJSONState = useSelector((state) => state.Map?.activitiesGeoJSONDict); - const isIAPPGeoJSONLoaded = useSelector((state) => state.Map?.IAPPGeoJSONDict !== undefined); - const mapLayers = useSelector((state) => state.Map.layers); - const MapMode = useSelector((state) => state.Map.MapMode); const recordSets = useSelector((state) => state.UserSettings?.recordSets); const connected = useSelector((state) => state.Network.connected); const [highlightedSet, setHighlightedSet] = useState(); - const [isActivitiesGeoJSONLoaded, setIsActivitiesGeoJSONLoaded] = useState(false); - const [loadMap, setLoadMap] = useState({}); const history = useHistory(); const dispatch = useDispatch(); @@ -37,23 +29,6 @@ export const Records = () => { dispatch(UserSettings.RecordSet.syncCacheStatusWithCacheService()); }, []); - useEffect(() => { - setIsActivitiesGeoJSONLoaded(activitiesGeoJSONState.hasOwnProperty('s3')); - }, [activitiesGeoJSONState]); - - useEffect(() => { - const rv = {}; - mapLayers.forEach((layer) => { - const geojson = layer?.type === RecordSetType.Activity ? isActivitiesGeoJSONLoaded : isIAPPGeoJSONLoaded; - if (MapMode !== 'VECTOR_ENDPOINT') { - rv[layer?.recordSetID] = !layer?.loading && geojson; - } else { - rv[layer?.recordSetID] = !layer?.loading; - } - }); - setLoadMap(rv); - }, [JSON.stringify(mapLayers), isActivitiesGeoJSONLoaded, isIAPPGeoJSONLoaded, MapMode]); - //Record set handlers: const handleToggleLabel = (set: string, e: MouseEvent) => { e.stopPropagation(); @@ -116,13 +91,7 @@ export const Records = () => { isDefaultRecordset={DEFAULT_RECORD_TYPES.includes(recordSets[set]?.recordSetName)} handleNameChange={handleNameChange} recordsetKey={set} - > - {!loadMap?.[set] && ( -
- -
- )} - + > = { severity: AlertSeverity.Error, subject: AlertSubjects.Network, autoClose: 10 + }, + automaticReconnectFailed: { + content: 'We were unable to bring you back online. Please try again later.', + severity: AlertSeverity.Error, + subject: AlertSubjects.Network, + autoClose: 10 } }; diff --git a/app/src/constants/misc.ts b/app/src/constants/misc.ts index bd8e38583..0bd5efb6a 100644 --- a/app/src/constants/misc.ts +++ b/app/src/constants/misc.ts @@ -11,3 +11,5 @@ export const MediumDateTimeFormat = 'MMMM D, YYYY, H:mm a'; //January 5, 2020, 3 export const LongDateFormat = 'dddd, MMMM D, YYYY, H:mm a'; //Monday, January 5, 2020, 3:30 pm export const LongDateTimeFormat = 'dddd, MMMM D, YYYY, H:mm a'; //Monday, January 5, 2020, 3:30 pm + +export const HEALTH_ENDPOINT = '/api/misc/version'; diff --git a/app/src/state/actions/network/NetworkActions.ts b/app/src/state/actions/network/NetworkActions.ts index 582d59fe1..3ee59c133 100644 --- a/app/src/state/actions/network/NetworkActions.ts +++ b/app/src/state/actions/network/NetworkActions.ts @@ -2,8 +2,17 @@ import { createAction } from '@reduxjs/toolkit'; class NetworkActions { private static readonly PREFIX = 'NetworkActions'; - static readonly online = createAction(`${this.PREFIX}/online`); static readonly offline = createAction(`${this.PREFIX}/offline`); + static readonly monitorHeartbeat = createAction(`${this.PREFIX}/monitorHeartbeat`, (cancel: boolean = false) => ({ + payload: cancel + })); + static readonly userLostConnection = createAction(`${this.PREFIX}/userLostConnection`); + static readonly manualReconnect = createAction(`${this.PREFIX}/manualReconnect`); + static readonly automaticReconnectFailed = createAction(`${this.PREFIX}/automaticReconnectFailed`); + static readonly checkInitConnection = createAction(`${this.PREFIX}/checkInitConnection`); + static readonly setAdministrativeStatus = createAction(`${this.PREFIX}/toggleAdministrativeStatus`); + static readonly setOperationalStatus = createAction(`${this.PREFIX}/setOperationalStatus`); + static readonly updateConnectionStatus = createAction(`${this.PREFIX}/updateConnectionStatus`); } export default NetworkActions; diff --git a/app/src/state/reducers/alertsAndPrompts.ts b/app/src/state/reducers/alertsAndPrompts.ts index 38ce9e25b..666fec052 100644 --- a/app/src/state/reducers/alertsAndPrompts.ts +++ b/app/src/state/reducers/alertsAndPrompts.ts @@ -20,6 +20,12 @@ const initialState: AlertsAndPromptsState = { const filterDuplicates = (key: keyof AlertMessage, matchValue: any, state: AlertMessage[]): any[] => state.filter((entry) => entry[key] !== matchValue); +const addAlert = (state: AlertMessage[], alert: AlertMessage): AlertMessage[] => [...state, { ...alert, id: nanoid() }]; +const addPrompt = (state: PromptAction[], prompt: PromptAction): PromptAction[] => [ + ...state, + { ...prompt, id: nanoid() } +]; + export function createAlertsAndPromptsReducer( configuration: AppConfig ): (AlertsAndPromptsState, AnyAction) => AlertsAndPromptsState { @@ -43,15 +49,15 @@ export function createAlertsAndPromptsReducer( draftState.prompts = []; } else if (RegExp(Prompt.NEW_PROMPT).exec(action.type)) { const newPrompt: PromptAction = action.payload; - draftState.prompts = [...state.prompts, { ...newPrompt, id: nanoid() }]; + draftState.prompts = addPrompt(state.prompts, newPrompt); } else if (RecordCache.requestCaching.fulfilled.match(action)) { - draftState.alerts = [...state.alerts, { ...cacheAlertMessages.recordsetCacheSuccess, id: nanoid() }]; + draftState.alerts = addAlert(state.alerts, cacheAlertMessages.recordsetCacheSuccess); } else if (RecordCache.requestCaching.rejected.match(action)) { - draftState.alerts = [...state.alerts, { ...cacheAlertMessages.recordsetCacheFailed, id: nanoid() }]; + draftState.alerts = addAlert(state.alerts, cacheAlertMessages.recordsetCacheFailed); } else if (RecordCache.deleteCache.rejected.match(action)) { - draftState.alerts = [...state.alerts, { ...cacheAlertMessages.recordsetDeleteCacheFailed, id: nanoid() }]; + draftState.alerts = addAlert(state.alerts, cacheAlertMessages.recordsetDeleteCacheFailed); } else if (RecordCache.deleteCache.fulfilled.match(action)) { - draftState.alerts = [...state.alerts, { ...cacheAlertMessages.recordsetDeleteCacheSuccess, id: nanoid() }]; + draftState.alerts = addAlert(state.alerts, cacheAlertMessages.recordsetDeleteCacheSuccess); } }); }; diff --git a/app/src/state/reducers/network.ts b/app/src/state/reducers/network.ts index ed2521638..0c57f7d7b 100644 --- a/app/src/state/reducers/network.ts +++ b/app/src/state/reducers/network.ts @@ -2,18 +2,43 @@ import { createNextState } from '@reduxjs/toolkit'; import { Draft } from 'immer'; import NetworkActions from 'state/actions/network/NetworkActions'; -interface Network { +const HEARTBEATS_TO_FAILURE = 4; + +/** + * @desc Network State properties + * @property { boolean } administrativeStatus user opt-in status for connecting to network + * @property { boolean } connected Combination variable for User wanting network connection, and connection being available + * @property { number } consecutiveHeartbeatFailures current number of concurrent failed health checks + * @property { boolean } operationalStatus status of connection to the API + */ +export interface NetworkState { + administrativeStatus: boolean; connected: boolean; + consecutiveHeartbeatFailures: number; + operationalStatus: boolean; } -function createNetworkReducer(initialStatus: Network) { - const initialState: Network = { - ...initialStatus - }; +const initialState: NetworkState = { + administrativeStatus: true, + connected: true, + consecutiveHeartbeatFailures: 0, + operationalStatus: true +}; +function createNetworkReducer() { return (state = initialState, action) => { - return createNextState(state, (draftState: Draft) => { - if (NetworkActions.online.match(action)) { + return createNextState(state, (draftState: Draft) => { + if (NetworkActions.setAdministrativeStatus.match(action)) { + draftState.administrativeStatus = action.payload; + draftState.consecutiveHeartbeatFailures = HEARTBEATS_TO_FAILURE; + } else if (NetworkActions.updateConnectionStatus.match(action)) { + if (action.payload) { + draftState.consecutiveHeartbeatFailures = 0; + } else { + draftState.consecutiveHeartbeatFailures++; + } + draftState.operationalStatus = draftState.consecutiveHeartbeatFailures < HEARTBEATS_TO_FAILURE; + } else if (NetworkActions.online.match(action)) { draftState.connected = true; } else if (NetworkActions.offline.match(action)) { draftState.connected = false; @@ -23,5 +48,5 @@ function createNetworkReducer(initialStatus: Network) { } const selectNetworkConnected: (state) => boolean = (state) => state.Network.connected; - -export { selectNetworkConnected, createNetworkReducer }; +const selectNetworkState: (state) => NetworkState = (state) => state.Network; +export { createNetworkReducer, selectNetworkConnected, selectNetworkState }; diff --git a/app/src/state/reducers/rootReducer.ts b/app/src/state/reducers/rootReducer.ts index adf6199d0..db094f940 100644 --- a/app/src/state/reducers/rootReducer.ts +++ b/app/src/state/reducers/rootReducer.ts @@ -14,7 +14,7 @@ import { createTrainingVideosReducer } from './training_videos'; import { createUserSettingsReducer, UserSettingsState } from './userSettings'; import { createIAPPSiteReducer } from './iappsite'; import { createConfigurationReducerWithDefaultState } from './configuration'; -import { createNetworkReducer } from './network'; +import { createNetworkReducer, NetworkState } from './network'; import { createUserInfoReducer } from './userInfo'; import { errorHandlerReducer } from './error_handler'; import { createOfflineActivityReducer, OfflineActivityState } from './offlineActivity'; @@ -65,7 +65,15 @@ function createRootReducer(config: AppConfig) { createAuthReducer(config) ), UserInfo: createUserInfoReducer({ loaded: false, accessRequested: false, activated: false }), - Network: createNetworkReducer({ connected: true }), + Network: persistReducer( + { + key: 'network', + storage: platformStorage, + stateReconciler: autoMergeLevel1, + whitelist: ['connected', 'administrativeStatus', 'operationalStatus'] + }, + createNetworkReducer() + ), ActivityPage: persistReducer( { key: 'activity', diff --git a/app/src/state/sagas/network.ts b/app/src/state/sagas/network.ts index 8f7132147..7c564b036 100644 --- a/app/src/state/sagas/network.ts +++ b/app/src/state/sagas/network.ts @@ -1,13 +1,87 @@ +import { PayloadAction } from '@reduxjs/toolkit'; +import { all, cancelled, delay, put, select, takeEvery, takeLatest } from 'redux-saga/effects'; import networkAlertMessages from 'constants/alerts/networkAlerts'; -import { all, put, select, takeEvery } from 'redux-saga/effects'; +import { HEALTH_ENDPOINT } from 'constants/misc'; import Alerts from 'state/actions/alerts/Alerts'; import NetworkActions from 'state/actions/network/NetworkActions'; +import { MOBILE } from 'state/build-time-config'; +import { selectConfiguration } from 'state/reducers/configuration'; import { OfflineActivitySyncState, selectOfflineActivity } from 'state/reducers/offlineActivity'; +import { selectNetworkState } from 'state/reducers/network'; -function* handle_NETWORK_GO_OFFLINE() { - yield put(Alerts.create(networkAlertMessages.userWentOffline)); +/* Utilities */ + +/** + * @desc Targets the API and checks for successful response. + * @param url Path to API Health check + * @returns Connection to API Succeeded + */ +const canConnectToNetwork = async (url: string): Promise => { + return await fetch(url) + .then((res) => res.ok) + .catch(() => false); +}; + +/** + * @desc Get the time between ticks for polling the heartbeat + * @param heartbeatFailures Current number of failed network attempts + * @returns { number } Greater of two delay values + */ +const getTimeBetweenTicks = (heartbeatFailures: number): number => { + const BASE_SECONDS_BETWEEN_FAILURE = 20; + const BASE_SECONDS_BETWEEN_SUCCESS = 60; + return Math.max( + BASE_SECONDS_BETWEEN_FAILURE * 1000 * Math.pow(1.1, heartbeatFailures), + BASE_SECONDS_BETWEEN_SUCCESS * 1000 + ); +}; + +/* Sagas */ + +/** + * @desc If administrative status enabled at launch, begin monitoring heartbeat + */ +function* handle_CHECK_INIT_CONNECTION() { + const { administrativeStatus } = yield select(selectNetworkState); + if (administrativeStatus) { + yield put(NetworkActions.monitorHeartbeat()); + } } +/** + * @desc Handles Manual reconnect attempt by user. Sets their administrative status to true + */ +function* handle_MANUAL_RECONNECT() { + const configuration = yield select(selectConfiguration); + yield put(NetworkActions.setAdministrativeStatus(true)); + if (yield canConnectToNetwork(configuration.API_BASE + HEALTH_ENDPOINT)) { + yield put(NetworkActions.monitorHeartbeat()); + } else { + yield put(Alerts.create(networkAlertMessages.attemptToReconnectFailed)); + } +} + +/** + * @desc Rolling function that targets the API to determine our online status. + */ +function* handle_MONITOR_HEARTBEAT(cancel: PayloadAction) { + if (!MOBILE || cancel.payload) { + return; + } + const configuration = yield select(selectConfiguration); + + do { + const { consecutiveHeartbeatFailures } = yield select(selectNetworkState); + const networkRequestSuccess = yield canConnectToNetwork(configuration.API_BASE + HEALTH_ENDPOINT); + yield put(NetworkActions.updateConnectionStatus(networkRequestSuccess)); + yield delay(getTimeBetweenTicks(consecutiveHeartbeatFailures)); + } while (!(yield cancelled())); +} + +/** + * @desc When user comes online, check for any existing unsychronized Activities. + * Restart the rolling Network status checks. + */ function* handle_NETWORK_GO_ONLINE() { const { serializedActivities } = yield select(selectOfflineActivity); const userHasUnsynchronizedActivities = Object.keys(serializedActivities).some( @@ -20,10 +94,51 @@ function* handle_NETWORK_GO_ONLINE() { } } +/** + * @desc Handler for Administrative status. + * When enabled, begin polling for heartbeat. + * When disabled, notify user they're offline, cancel heartbeat polling, + * @param newStatus new Administrative status + */ +function* handle_SET_ADMINISTRATIVE_STATUS(newStatus: PayloadAction) { + if (newStatus.payload) { + yield put(NetworkActions.monitorHeartbeat()); + } else { + yield all([ + put(NetworkActions.monitorHeartbeat(true)), // Cancel loop + put(Alerts.create(networkAlertMessages.userWentOffline)), + put(NetworkActions.updateConnectionStatus(true)) + ]); + } +} + +/** + * @desc Handler for updating current connection status given boolean flags. + * In case of match, do nothing. + */ +function* handle_UPDATE_CONNECTION_STATUS() { + const { administrativeStatus, operationalStatus, connected } = yield select(selectNetworkState); + + const networkLive = administrativeStatus && operationalStatus; + const disconnected = connected && administrativeStatus && !operationalStatus; + if (disconnected) { + yield put(Alerts.create(networkAlertMessages.userLostConnection)); + } + if (connected && !networkLive) { + yield put(NetworkActions.offline()); + } else if (!connected && networkLive) { + yield put(NetworkActions.online()); + } +} + function* networkSaga() { yield all([ - takeEvery(NetworkActions.offline, handle_NETWORK_GO_OFFLINE), - takeEvery(NetworkActions.online, handle_NETWORK_GO_ONLINE) + takeEvery(NetworkActions.manualReconnect, handle_MANUAL_RECONNECT), + takeLatest(NetworkActions.monitorHeartbeat, handle_MONITOR_HEARTBEAT), + takeEvery(NetworkActions.online, handle_NETWORK_GO_ONLINE), + takeEvery(NetworkActions.setAdministrativeStatus, handle_SET_ADMINISTRATIVE_STATUS), + takeEvery(NetworkActions.updateConnectionStatus, handle_UPDATE_CONNECTION_STATUS), + takeEvery(NetworkActions.checkInitConnection, handle_CHECK_INIT_CONNECTION) ]); } diff --git a/app/src/state/store.ts b/app/src/state/store.ts index f11f88de1..27418a3a1 100644 --- a/app/src/state/store.ts +++ b/app/src/state/store.ts @@ -1,4 +1,4 @@ -import { configureStore, ThunkDispatch } from '@reduxjs/toolkit'; +import { configureStore } from '@reduxjs/toolkit'; import createSagaMiddleware from 'redux-saga'; import { createLogger } from 'redux-logger'; import { createBrowserHistory } from 'history'; @@ -19,6 +19,7 @@ import userSettingsSaga from './sagas/userSettings'; import { createSagaCrashHandler } from './sagas/error_handler'; import { AppConfig } from './config'; import { DEBUG } from './build-time-config'; +import NetworkActions from './actions/network/NetworkActions'; const historySingleton = createBrowserHistory(); @@ -79,6 +80,7 @@ export function setupStore(configuration: AppConfig) { sagaMiddleware.run(emailTemplatesSaga); sagaMiddleware.run(networkSaga); + store.dispatch(NetworkActions.checkInitConnection()); store.dispatch({ type: AUTH_INITIALIZE_REQUEST }); historySingleton.listen((location) => {