@dhis2/app-service-offline 3.17.0 → 3.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitignore +5 -0
- package/build/cjs/declarations.d.js +1 -0
- package/build/es/declarations.d.js +1 -0
- package/d2.config.js +9 -0
- package/jest.config.js +14 -0
- package/package.json +3 -3
- package/src/__tests__/integration.test.tsx +341 -0
- package/src/declarations.d.ts +1 -0
- package/src/index.ts +12 -0
- package/src/lib/__tests__/cacheable-section-state.test.tsx +45 -0
- package/src/lib/__tests__/clear-sensitive-caches.test.ts +182 -0
- package/src/lib/__tests__/network-status.test.tsx +496 -0
- package/src/lib/__tests__/offline-provider.test.tsx +116 -0
- package/src/lib/__tests__/use-cacheable-section.test.tsx +280 -0
- package/src/lib/__tests__/use-online-status-message.test.tsx +27 -0
- package/src/lib/cacheable-section-state.tsx +269 -0
- package/src/lib/cacheable-section.tsx +193 -0
- package/src/lib/clear-sensitive-caches.ts +92 -0
- package/src/lib/dhis2-connection-status/dev-debug-log.ts +20 -0
- package/src/lib/dhis2-connection-status/dhis2-connection-status.test.tsx +947 -0
- package/src/lib/dhis2-connection-status/dhis2-connection-status.tsx +241 -0
- package/src/lib/dhis2-connection-status/index.ts +4 -0
- package/src/lib/dhis2-connection-status/is-ping-available.test.ts +32 -0
- package/src/lib/dhis2-connection-status/is-ping-available.ts +31 -0
- package/src/lib/dhis2-connection-status/smart-interval.ts +206 -0
- package/src/lib/dhis2-connection-status/use-ping-query.ts +14 -0
- package/src/lib/global-state-service.tsx +110 -0
- package/src/lib/network-status.ts +80 -0
- package/src/lib/offline-interface.tsx +57 -0
- package/src/lib/offline-provider.tsx +43 -0
- package/src/lib/online-status-message.tsx +47 -0
- package/src/setupRTL.ts +1 -0
- package/src/types.ts +66 -0
- package/src/utils/__tests__/render-counter.test.tsx +45 -0
- package/src/utils/render-counter.tsx +22 -0
- package/src/utils/test-mocks.ts +47 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { useConfig } from '@dhis2/app-service-config'
|
|
2
|
+
import { throttle } from 'lodash'
|
|
3
|
+
import PropTypes from 'prop-types'
|
|
4
|
+
import React, {
|
|
5
|
+
useCallback,
|
|
6
|
+
useState,
|
|
7
|
+
useRef,
|
|
8
|
+
useMemo,
|
|
9
|
+
useEffect,
|
|
10
|
+
useContext,
|
|
11
|
+
} from 'react'
|
|
12
|
+
import { useOfflineInterface } from '../offline-interface'
|
|
13
|
+
import { devDebugLog } from './dev-debug-log'
|
|
14
|
+
import { isPingAvailable } from './is-ping-available'
|
|
15
|
+
import createSmartInterval, { SmartInterval } from './smart-interval'
|
|
16
|
+
import { usePingQuery } from './use-ping-query'
|
|
17
|
+
|
|
18
|
+
// Utils for saving 'last connected' datetime in local storage
|
|
19
|
+
const lastConnectedKey = 'dhis2.lastConnected'
|
|
20
|
+
type AppName = string | undefined
|
|
21
|
+
export const getLastConnectedKey = (appName?: AppName) =>
|
|
22
|
+
appName ? `${lastConnectedKey}.${appName}` : lastConnectedKey
|
|
23
|
+
const updateLastConnected = (appName: AppName) => {
|
|
24
|
+
// use Date.now() because it's easier to mock for easier unit testing
|
|
25
|
+
const now = new Date(Date.now())
|
|
26
|
+
localStorage.setItem(getLastConnectedKey(appName), now.toUTCString())
|
|
27
|
+
return now
|
|
28
|
+
}
|
|
29
|
+
const getLastConnected = (appName: AppName) => {
|
|
30
|
+
const lastConnected = localStorage.getItem(getLastConnectedKey(appName))
|
|
31
|
+
return lastConnected ? new Date(lastConnected) : null
|
|
32
|
+
}
|
|
33
|
+
const clearLastConnected = (appName: AppName) => {
|
|
34
|
+
localStorage.removeItem(getLastConnectedKey(appName))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Dhis2ConnectionStatus {
|
|
38
|
+
isConnected: boolean
|
|
39
|
+
isDisconnected: boolean
|
|
40
|
+
lastConnected: Date | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const Dhis2ConnectionStatusContext = React.createContext({
|
|
44
|
+
isConnected: true,
|
|
45
|
+
isDisconnected: false,
|
|
46
|
+
lastConnected: null,
|
|
47
|
+
} as Dhis2ConnectionStatus)
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Provides a boolean indicating client's connection to the DHIS2 server,
|
|
51
|
+
* which is different from connection to the internet.
|
|
52
|
+
*
|
|
53
|
+
* The context provider subscribes to messages from the SW tracking successes
|
|
54
|
+
* and failures of requests to the DHIS2 server to determine connection status,
|
|
55
|
+
* and then will initiate periodic pings if there are no incidental requests in
|
|
56
|
+
* order to check the connection consistently
|
|
57
|
+
*/
|
|
58
|
+
export const Dhis2ConnectionStatusProvider = ({
|
|
59
|
+
children,
|
|
60
|
+
}: {
|
|
61
|
+
children: React.ReactNode
|
|
62
|
+
}): JSX.Element => {
|
|
63
|
+
const offlineInterface = useOfflineInterface()
|
|
64
|
+
const { appName, serverVersion } = useConfig()
|
|
65
|
+
// The offline interface persists the latest update from the SW so that
|
|
66
|
+
// this hook can initialize to an accurate value. The App Adapter in the
|
|
67
|
+
// platform waits for this value to be populated before rendering the
|
|
68
|
+
// the App Runtime provider (including this), but if that is not done,
|
|
69
|
+
// `latestIsConnected` may be `null` depending on the outcome of race
|
|
70
|
+
// conditions between the SW and the React component tree.
|
|
71
|
+
const [isConnected, setIsConnected] = useState(
|
|
72
|
+
offlineInterface.latestIsConnected
|
|
73
|
+
)
|
|
74
|
+
const ping = usePingQuery()
|
|
75
|
+
const smartIntervalRef = useRef(null as null | SmartInterval)
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Update state, reset ping backoff if changed, and update
|
|
79
|
+
* the lastConnected value in localStorage
|
|
80
|
+
*/
|
|
81
|
+
const updateConnectedState = useCallback(
|
|
82
|
+
(newIsConnected: boolean | null) => {
|
|
83
|
+
// use 'set' with a function as param to get latest isConnected
|
|
84
|
+
// without needing it as a dependency for useCallback
|
|
85
|
+
setIsConnected((prevIsConnected) => {
|
|
86
|
+
devDebugLog('[D2CS] updating state:', {
|
|
87
|
+
prevIsConnected,
|
|
88
|
+
newIsConnected,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
if (newIsConnected !== prevIsConnected) {
|
|
92
|
+
// if value changed, reset ping interval to initial delay
|
|
93
|
+
smartIntervalRef.current?.reset()
|
|
94
|
+
|
|
95
|
+
if (newIsConnected) {
|
|
96
|
+
// Need to clear this here so it doesn't affect another
|
|
97
|
+
// session that starts while offline
|
|
98
|
+
clearLastConnected(appName)
|
|
99
|
+
} else {
|
|
100
|
+
updateLastConnected(appName)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return newIsConnected
|
|
104
|
+
})
|
|
105
|
+
},
|
|
106
|
+
[appName]
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
// Note that the SW is configured to not cache ping requests and won't
|
|
110
|
+
// trigger `handleChange` below to avoid redundant signals. This also
|
|
111
|
+
// helps to detect the connectivity status when the SW is not available
|
|
112
|
+
// for some reason (maybe private browsing, first installation, or
|
|
113
|
+
// insecure browser context)
|
|
114
|
+
const pingAndHandleStatus = useCallback(() => {
|
|
115
|
+
return ping()
|
|
116
|
+
.then(() => {
|
|
117
|
+
// Ping is successful; set 'connected'
|
|
118
|
+
updateConnectedState(true)
|
|
119
|
+
})
|
|
120
|
+
.catch((err) => {
|
|
121
|
+
console.error('Ping failed:', err.message)
|
|
122
|
+
updateConnectedState(false)
|
|
123
|
+
})
|
|
124
|
+
}, [ping, updateConnectedState])
|
|
125
|
+
|
|
126
|
+
/** Called when SW reports updates from incidental network traffic */
|
|
127
|
+
const onUpdate = useCallback(
|
|
128
|
+
({ isConnected: newIsConnected }: { isConnected: any }) => {
|
|
129
|
+
devDebugLog('[D2CS] handling update from sw')
|
|
130
|
+
|
|
131
|
+
// Snooze ping timer to reduce pings since we know state from SW
|
|
132
|
+
smartIntervalRef.current?.snooze()
|
|
133
|
+
updateConnectedState(newIsConnected)
|
|
134
|
+
},
|
|
135
|
+
[updateConnectedState]
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
// If the /api/ping endpoint is not available on this instance, skip
|
|
140
|
+
// pinging with the smart interval. Just use the service worker
|
|
141
|
+
if (!serverVersion || !isPingAvailable(serverVersion)) {
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Only create the smart interval once
|
|
146
|
+
const smartInterval = createSmartInterval({
|
|
147
|
+
// don't ping if window isn't focused or visible
|
|
148
|
+
initialPauseValue:
|
|
149
|
+
!document.hasFocus() || document.visibilityState !== 'visible',
|
|
150
|
+
callback: pingAndHandleStatus,
|
|
151
|
+
})
|
|
152
|
+
smartIntervalRef.current = smartInterval
|
|
153
|
+
|
|
154
|
+
const handleBlur = () => smartInterval.pause()
|
|
155
|
+
const handleFocus = () => smartInterval.resume()
|
|
156
|
+
// Pinging when going offline should be low/no-cost in both online and
|
|
157
|
+
// local servers
|
|
158
|
+
const handleOffline = () => smartInterval.invokeCallbackImmediately()
|
|
159
|
+
// Pinging when going online has a cost but improves responsiveness of
|
|
160
|
+
// the connection status -- only do it once every 15 seconds at most
|
|
161
|
+
const handleOnline = throttle(
|
|
162
|
+
() => smartInterval.invokeCallbackImmediately(),
|
|
163
|
+
15000
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
window.addEventListener('blur', handleBlur)
|
|
167
|
+
window.addEventListener('focus', handleFocus)
|
|
168
|
+
window.addEventListener('offline', handleOffline)
|
|
169
|
+
window.addEventListener('online', handleOnline)
|
|
170
|
+
|
|
171
|
+
return () => {
|
|
172
|
+
window.removeEventListener('blur', handleBlur)
|
|
173
|
+
window.removeEventListener('focus', handleFocus)
|
|
174
|
+
window.removeEventListener('offline', handleOffline)
|
|
175
|
+
window.removeEventListener('online', handleOnline)
|
|
176
|
+
|
|
177
|
+
// clean up smart interval and throttled function
|
|
178
|
+
smartInterval.clear()
|
|
179
|
+
handleOnline.cancel()
|
|
180
|
+
}
|
|
181
|
+
}, [pingAndHandleStatus, serverVersion])
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (!offlineInterface.subscribeToDhis2ConnectionStatus) {
|
|
185
|
+
// Missing this functionality from the offline interface --
|
|
186
|
+
// use a ping on startup to get the status
|
|
187
|
+
smartIntervalRef.current?.invokeCallbackImmediately()
|
|
188
|
+
console.warn(
|
|
189
|
+
'Please upgrade to @dhis2/cli-app-scripts@>10.3.8 for full connection status features'
|
|
190
|
+
)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const unsubscribe = offlineInterface.subscribeToDhis2ConnectionStatus({
|
|
195
|
+
onUpdate,
|
|
196
|
+
})
|
|
197
|
+
return () => {
|
|
198
|
+
unsubscribe()
|
|
199
|
+
}
|
|
200
|
+
}, [offlineInterface, onUpdate])
|
|
201
|
+
|
|
202
|
+
// Memoize this value to prevent unnecessary rerenders of context provider
|
|
203
|
+
const contextValue = useMemo(() => {
|
|
204
|
+
// in the unlikely circumstance that offlineInterface.latestIsConnected
|
|
205
|
+
// is `null` or `undefined` when this initializes, fail safe by defaulting to
|
|
206
|
+
// `isConnected: true`. A ping or SW update should update the status shortly.
|
|
207
|
+
const validatedIsConnected = isConnected ?? true
|
|
208
|
+
return {
|
|
209
|
+
isConnected: validatedIsConnected,
|
|
210
|
+
isDisconnected: !validatedIsConnected,
|
|
211
|
+
lastConnected: validatedIsConnected
|
|
212
|
+
? null
|
|
213
|
+
: // Only evaluate if disconnected, since local storage
|
|
214
|
+
// is synchronous and disk-based.
|
|
215
|
+
// If lastConnected is not set in localStorage though, set it.
|
|
216
|
+
// (relevant on startup)
|
|
217
|
+
getLastConnected(appName) || updateLastConnected(appName),
|
|
218
|
+
}
|
|
219
|
+
}, [isConnected, appName])
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<Dhis2ConnectionStatusContext.Provider value={contextValue}>
|
|
223
|
+
{children}
|
|
224
|
+
</Dhis2ConnectionStatusContext.Provider>
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
Dhis2ConnectionStatusProvider.propTypes = {
|
|
228
|
+
children: PropTypes.node,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export const useDhis2ConnectionStatus = (): Dhis2ConnectionStatus => {
|
|
232
|
+
const context = useContext(Dhis2ConnectionStatusContext)
|
|
233
|
+
|
|
234
|
+
if (!context) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
'useDhis2ConnectionStatus must be used within a Dhis2ConnectionStatus provider'
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return context
|
|
241
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Version } from '@dhis2/app-service-config'
|
|
2
|
+
import { isPingAvailable } from './is-ping-available'
|
|
3
|
+
|
|
4
|
+
const fixedVersions: Version[] = [
|
|
5
|
+
{ full: 'unimportant', major: 2, minor: 40, patch: 0 },
|
|
6
|
+
{ full: 'unimportant', major: 2, minor: 39, patch: 2 },
|
|
7
|
+
{ full: 'unimportant', major: 2, minor: 38, patch: 4 },
|
|
8
|
+
{ full: 'unimportant', major: 2, minor: 37, patch: 10 },
|
|
9
|
+
{ full: 'unimportant', major: 2, minor: 40, tag: 'SNAPSHOT' },
|
|
10
|
+
{ full: 'unimportant', major: 2, minor: 3291, patch: 0 },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const unsupportedVersions: Version[] = [
|
|
14
|
+
{ full: 'unimportant', major: 2, minor: 39, patch: 1 },
|
|
15
|
+
{ full: 'unimportant', major: 2, minor: 38, patch: 2 },
|
|
16
|
+
{ full: 'unimportant', major: 2, minor: 37, patch: 9 },
|
|
17
|
+
{ full: 'unimportant', major: 2, minor: 36, patch: 12 },
|
|
18
|
+
{ full: 'unimportant', major: 2, minor: 35, patch: 0 },
|
|
19
|
+
{ full: 'unimportant', major: 2, minor: 0, patch: 0 },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
test('supported server versions pass', () => {
|
|
23
|
+
fixedVersions.forEach((version) => {
|
|
24
|
+
expect(isPingAvailable(version)).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('unsupported server versions fail', () => {
|
|
29
|
+
unsupportedVersions.forEach((version) => {
|
|
30
|
+
expect(isPingAvailable(version)).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Version } from '@dhis2/app-service-config'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Checks the server version to see if the /api/ping endpoint is available for
|
|
5
|
+
* this version.
|
|
6
|
+
*
|
|
7
|
+
* The endpoint was released for versions 2.37.10, 2.38.4, 2.39.2, and 2.40.0
|
|
8
|
+
* (see DHIS2-14531). All versions below that are considered unsupported
|
|
9
|
+
*
|
|
10
|
+
* If the patchVersion is undefined, it's assumed to be a dev server that's
|
|
11
|
+
* newer than the fix versions listed above
|
|
12
|
+
*
|
|
13
|
+
* Major versions above 2 aren't supported 🤷♂️
|
|
14
|
+
*/
|
|
15
|
+
export function isPingAvailable(serverVersion: Version) {
|
|
16
|
+
if (!serverVersion) {
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { minor, patch } = serverVersion
|
|
21
|
+
switch (minor) {
|
|
22
|
+
case 39:
|
|
23
|
+
return patch === undefined || patch >= 2
|
|
24
|
+
case 38:
|
|
25
|
+
return patch === undefined || patch >= 4
|
|
26
|
+
case 37:
|
|
27
|
+
return patch === undefined || patch >= 10
|
|
28
|
+
default:
|
|
29
|
+
return minor >= 40
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { devDebugLog } from './dev-debug-log'
|
|
2
|
+
|
|
3
|
+
// Exported for tests
|
|
4
|
+
export const DEFAULT_INITIAL_DELAY_MS = 1000 * 30 // 30 sec
|
|
5
|
+
export const DEFAULT_MAX_DELAY_MS = 1000 * 60 * 5 // 5 min
|
|
6
|
+
export const DEFAULT_INCREMENT_FACTOR = 1.5
|
|
7
|
+
const throwErrorIfNoCallbackIsProvided = (): void => {
|
|
8
|
+
throw new Error('Provide a callback')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SmartInterval {
|
|
12
|
+
clear: () => void
|
|
13
|
+
pause: () => void
|
|
14
|
+
resume: () => void
|
|
15
|
+
invokeCallbackImmediately: () => void
|
|
16
|
+
snooze: () => void
|
|
17
|
+
reset: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function createSmartInterval({
|
|
21
|
+
initialDelay = DEFAULT_INITIAL_DELAY_MS,
|
|
22
|
+
maxDelay = DEFAULT_MAX_DELAY_MS,
|
|
23
|
+
delayIncrementFactor = DEFAULT_INCREMENT_FACTOR,
|
|
24
|
+
initialPauseValue = false,
|
|
25
|
+
callback = throwErrorIfNoCallbackIsProvided,
|
|
26
|
+
} = {}): SmartInterval {
|
|
27
|
+
const state = {
|
|
28
|
+
paused: initialPauseValue,
|
|
29
|
+
delay: initialDelay,
|
|
30
|
+
// Timeout types are weird; this dummy timeout helps fix them:
|
|
31
|
+
// (named to help debugging in tests)
|
|
32
|
+
timeout: setTimeout(function dummyTimeout() {
|
|
33
|
+
return
|
|
34
|
+
}, 0),
|
|
35
|
+
standbyCallback: null as null | (() => void),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Increment delay by the increment factor, up to a max value */
|
|
39
|
+
function incrementDelay() {
|
|
40
|
+
const newDelay = Math.min(state.delay * delayIncrementFactor, maxDelay)
|
|
41
|
+
devDebugLog('[SI] incrementing delay', {
|
|
42
|
+
prev: state.delay,
|
|
43
|
+
new: newDelay,
|
|
44
|
+
})
|
|
45
|
+
state.delay = newDelay
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function invokeCallbackAndHandleDelay(): void {
|
|
49
|
+
// Increment delay before calling callback, so callback can potentially
|
|
50
|
+
// reset the delay to initial before starting the next timeout
|
|
51
|
+
incrementDelay()
|
|
52
|
+
callback()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function clearTimeoutAndStart(): void {
|
|
56
|
+
devDebugLog('[SI] clearing and starting timeout', {
|
|
57
|
+
delay: state.delay,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// Prevent parallel timeouts from occuring
|
|
61
|
+
// (weird note: `if (this.timeout) { clearTimeout(this.timeout) }`
|
|
62
|
+
// does NOT work for some reason)
|
|
63
|
+
clearTimeout(state.timeout)
|
|
64
|
+
|
|
65
|
+
// A timeout is used instead of an interval for handling slow execution
|
|
66
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/setInterval#ensure_that_execution_duration_is_shorter_than_interval_frequency
|
|
67
|
+
state.timeout = setTimeout(function callbackAndRestart() {
|
|
68
|
+
if (state.paused) {
|
|
69
|
+
devDebugLog('[SI] entering regular standby')
|
|
70
|
+
|
|
71
|
+
// If paused, prepare a 'standby callback' to be invoked when
|
|
72
|
+
// `resume()` is called (see its definition below).
|
|
73
|
+
// The timer will not be started again until the standbyCallback
|
|
74
|
+
// is invoked.
|
|
75
|
+
state.standbyCallback = () => {
|
|
76
|
+
invokeCallbackAndHandleDelay()
|
|
77
|
+
clearTimeoutAndStart()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Otherwise, invoke callback
|
|
84
|
+
invokeCallbackAndHandleDelay()
|
|
85
|
+
// and start process over again
|
|
86
|
+
clearTimeoutAndStart()
|
|
87
|
+
}, state.delay)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Stop the interval. Used for cleaning up */
|
|
91
|
+
function clear(): void {
|
|
92
|
+
clearTimeout(state.timeout)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Invoke the provided callback immediately and start the timer over.
|
|
97
|
+
* The timeout to the next invocation will not be increased
|
|
98
|
+
* (unless the timer fully elapses while this interval is paused).
|
|
99
|
+
*
|
|
100
|
+
* If the interval is 'paused', it will not invoke the callback immediately,
|
|
101
|
+
* but enter a 'partial standby', which will invoke the callback upon
|
|
102
|
+
* resuming, but without incrementing the delay. If the regular timeout
|
|
103
|
+
* elapses while paused, the regular standby is entered, overwriting this
|
|
104
|
+
* partial standby.
|
|
105
|
+
*/
|
|
106
|
+
function invokeCallbackImmediately(): void {
|
|
107
|
+
if (state.paused) {
|
|
108
|
+
if (state.standbyCallback === null) {
|
|
109
|
+
// If there is not an existing standbyCallback,
|
|
110
|
+
// set one to be called upon `resume()`
|
|
111
|
+
// (but don't overwrite a previous callback).
|
|
112
|
+
// See setTimeout call above too.
|
|
113
|
+
// The timed out function set in `clearTimeoutAndStart` may
|
|
114
|
+
// overwrite this callback if the timer elapses, so that the
|
|
115
|
+
// timeout delay gets incremented appropriately.
|
|
116
|
+
devDebugLog('[SI] entering standby without timer increment')
|
|
117
|
+
|
|
118
|
+
state.standbyCallback = () => {
|
|
119
|
+
// Invoke callback and start timer without incrementing
|
|
120
|
+
callback()
|
|
121
|
+
clearTimeoutAndStart()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Skip rest of execution while paused
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Invoke callback and start timer without incrementing
|
|
130
|
+
callback()
|
|
131
|
+
clearTimeoutAndStart()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Sets a 'paused' flag (doesn't yet stop the timer):
|
|
136
|
+
*
|
|
137
|
+
* If the main timer elapses or `invokeCallbackImmediately` is called
|
|
138
|
+
* while the interval is paused, the timer will not be started again.
|
|
139
|
+
* Instead, a callback function will be saved that will be called when
|
|
140
|
+
* `resume()` is called (see its definition below)
|
|
141
|
+
*
|
|
142
|
+
* This decreases execution activity while 'paused'
|
|
143
|
+
*/
|
|
144
|
+
function pause(): void {
|
|
145
|
+
devDebugLog('[SI] pausing')
|
|
146
|
+
|
|
147
|
+
state.paused = true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Removes 'paused' state
|
|
152
|
+
*
|
|
153
|
+
* If the interval is in 'standby', trigger the saved 'standbyCallback',
|
|
154
|
+
* which should start the interval timer again
|
|
155
|
+
*/
|
|
156
|
+
function resume(): void {
|
|
157
|
+
devDebugLog('[SI] resuming', { standbyCb: state.standbyCallback })
|
|
158
|
+
|
|
159
|
+
// Clear paused state
|
|
160
|
+
state.paused = false
|
|
161
|
+
|
|
162
|
+
// If in standby, invoke the saved callback
|
|
163
|
+
// (invokeCallbackImmediately and clearTimeoutAndStart can set a
|
|
164
|
+
// standby callback)
|
|
165
|
+
if (state.standbyCallback !== null) {
|
|
166
|
+
state.standbyCallback()
|
|
167
|
+
// Remove existing standbyCallback
|
|
168
|
+
state.standbyCallback = null
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Restart the timer to the next callback invocation, using the current
|
|
174
|
+
* delay
|
|
175
|
+
*
|
|
176
|
+
* Expected to be called to delay a ping in response to incidental network
|
|
177
|
+
* traffic, for example
|
|
178
|
+
*/
|
|
179
|
+
function snooze(): void {
|
|
180
|
+
devDebugLog('[SI] snoozing timeout')
|
|
181
|
+
|
|
182
|
+
clearTimeoutAndStart()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Cancels the current timeout and starts a new one with the initial delay
|
|
187
|
+
*/
|
|
188
|
+
function reset(): void {
|
|
189
|
+
devDebugLog('[SI] resetting interval from beginning')
|
|
190
|
+
|
|
191
|
+
state.delay = initialDelay
|
|
192
|
+
clearTimeoutAndStart()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Start the timer!
|
|
196
|
+
clearTimeoutAndStart()
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
clear,
|
|
200
|
+
pause,
|
|
201
|
+
resume,
|
|
202
|
+
invokeCallbackImmediately,
|
|
203
|
+
snooze,
|
|
204
|
+
reset,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useConfig } from '@dhis2/app-service-config'
|
|
2
|
+
import { useCallback } from 'react'
|
|
3
|
+
|
|
4
|
+
// This function has a separate file for easier mocking
|
|
5
|
+
|
|
6
|
+
export function usePingQuery(): () => Promise<any> {
|
|
7
|
+
const { baseUrl } = useConfig()
|
|
8
|
+
|
|
9
|
+
// This endpoint doesn't need any extra headers or handling since it's
|
|
10
|
+
// public. It doesn't extend the user session. See DHIS2-14531
|
|
11
|
+
const ping = useCallback(() => fetch(baseUrl + '/api/ping'), [baseUrl])
|
|
12
|
+
|
|
13
|
+
return ping
|
|
14
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import isEqual from 'lodash/isEqual'
|
|
2
|
+
import PropTypes from 'prop-types'
|
|
3
|
+
import React, {
|
|
4
|
+
useEffect,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useState,
|
|
8
|
+
useMemo,
|
|
9
|
+
} from 'react'
|
|
10
|
+
import {
|
|
11
|
+
GlobalStateStore,
|
|
12
|
+
GlobalStateStoreMutateMethod,
|
|
13
|
+
GlobalStateMutation,
|
|
14
|
+
GlobalStateStoreMutationCreator,
|
|
15
|
+
} from '../types'
|
|
16
|
+
|
|
17
|
+
// This file creates a redux-like state management service using React context
|
|
18
|
+
// that minimizes unnecessary rerenders that consume the context.
|
|
19
|
+
// See more at https://github.com/amcgee/state-service-poc
|
|
20
|
+
|
|
21
|
+
const identity = (state: any) => state
|
|
22
|
+
|
|
23
|
+
export const createStore = (initialState = {}): GlobalStateStore => {
|
|
24
|
+
const subscriptions: Set<(state: any) => void> = new Set()
|
|
25
|
+
let state = initialState
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
getState: () => state,
|
|
29
|
+
subscribe: (callback) => {
|
|
30
|
+
subscriptions.add(callback)
|
|
31
|
+
},
|
|
32
|
+
unsubscribe: (callback) => {
|
|
33
|
+
subscriptions.delete(callback)
|
|
34
|
+
},
|
|
35
|
+
mutate: (mutation) => {
|
|
36
|
+
state = mutation(state)
|
|
37
|
+
for (const callback of subscriptions) {
|
|
38
|
+
callback(state)
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const GlobalStateContext = React.createContext(createStore())
|
|
45
|
+
const useGlobalStateStore = () => useContext(GlobalStateContext)
|
|
46
|
+
|
|
47
|
+
export const GlobalStateProvider = ({
|
|
48
|
+
store,
|
|
49
|
+
children,
|
|
50
|
+
}: {
|
|
51
|
+
store: GlobalStateStore
|
|
52
|
+
children: React.ReactNode
|
|
53
|
+
}): JSX.Element => (
|
|
54
|
+
<GlobalStateContext.Provider value={store}>
|
|
55
|
+
{children}
|
|
56
|
+
</GlobalStateContext.Provider>
|
|
57
|
+
)
|
|
58
|
+
GlobalStateProvider.propTypes = {
|
|
59
|
+
children: PropTypes.node,
|
|
60
|
+
store: PropTypes.shape({}),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const useGlobalState = (
|
|
64
|
+
selector = identity
|
|
65
|
+
): [any, GlobalStateStoreMutateMethod] => {
|
|
66
|
+
const store = useGlobalStateStore()
|
|
67
|
+
const [selectedState, setSelectedState] = useState(
|
|
68
|
+
selector(store.getState())
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
// NEW: deep equality check before updating
|
|
73
|
+
const callback = (state: any) => {
|
|
74
|
+
const newSelectedState = selector(state)
|
|
75
|
+
// Use this form to avoid having `selectedState` as a dep in here
|
|
76
|
+
setSelectedState((currentSelectedState: any) => {
|
|
77
|
+
// Second condition handles case where a selected object gets
|
|
78
|
+
// deleted, but state does not update
|
|
79
|
+
if (
|
|
80
|
+
!isEqual(currentSelectedState, newSelectedState) ||
|
|
81
|
+
currentSelectedState === undefined
|
|
82
|
+
) {
|
|
83
|
+
return newSelectedState
|
|
84
|
+
}
|
|
85
|
+
return currentSelectedState
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
store.subscribe(callback)
|
|
89
|
+
// Make sure to update state when selector changes
|
|
90
|
+
callback(store.getState())
|
|
91
|
+
return () => store.unsubscribe(callback)
|
|
92
|
+
}, [store, selector])
|
|
93
|
+
|
|
94
|
+
return useMemo(
|
|
95
|
+
() => [selectedState, store.mutate],
|
|
96
|
+
[selectedState, store.mutate]
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function useGlobalStateMutation<Type>(
|
|
101
|
+
mutationCreator: GlobalStateStoreMutationCreator<Type>
|
|
102
|
+
): GlobalStateMutation<Type> {
|
|
103
|
+
const store = useGlobalStateStore()
|
|
104
|
+
return useCallback(
|
|
105
|
+
(...args) => {
|
|
106
|
+
store.mutate(mutationCreator(...args))
|
|
107
|
+
},
|
|
108
|
+
[mutationCreator, store]
|
|
109
|
+
)
|
|
110
|
+
}
|