@dhis2/app-service-offline 3.17.0-beta.4 → 3.17.1
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,193 @@
|
|
|
1
|
+
import PropTypes from 'prop-types'
|
|
2
|
+
import React, { useCallback, useEffect, useMemo } from 'react'
|
|
3
|
+
import { flushSync } from 'react-dom'
|
|
4
|
+
import { RecordingState } from '../types'
|
|
5
|
+
import { useRecordingState, useCachedSection } from './cacheable-section-state'
|
|
6
|
+
import { useOfflineInterface } from './offline-interface'
|
|
7
|
+
|
|
8
|
+
const recordingStates: { [index: string]: RecordingState } = {
|
|
9
|
+
default: 'default',
|
|
10
|
+
pending: 'pending',
|
|
11
|
+
recording: 'recording',
|
|
12
|
+
error: 'error',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface CacheableSectionStartRecordingOptions {
|
|
16
|
+
recordingTimeoutDelay?: number
|
|
17
|
+
onStarted?: () => void
|
|
18
|
+
onCompleted?: () => void
|
|
19
|
+
onError?: (err: Error) => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type CacheableSectionStartRecording = (
|
|
23
|
+
options?: CacheableSectionStartRecordingOptions
|
|
24
|
+
) => Promise<any>
|
|
25
|
+
|
|
26
|
+
interface CacheableSectionControls {
|
|
27
|
+
recordingState: RecordingState
|
|
28
|
+
startRecording: CacheableSectionStartRecording
|
|
29
|
+
lastUpdated: Date | undefined
|
|
30
|
+
isCached: boolean
|
|
31
|
+
remove: () => Promise<boolean>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Returns the main controls for a cacheable section and manages recording
|
|
36
|
+
* state, which affects the render state of the CacheableSection component.
|
|
37
|
+
* Also returns the cached status of the section, which come straight from
|
|
38
|
+
* the `useCachedSection` hook.
|
|
39
|
+
*
|
|
40
|
+
* @param {String} id
|
|
41
|
+
* @returns {Object}
|
|
42
|
+
*/
|
|
43
|
+
export function useCacheableSection(id: string): CacheableSectionControls {
|
|
44
|
+
const offlineInterface = useOfflineInterface()
|
|
45
|
+
const { isCached, lastUpdated, remove, syncCachedSections } =
|
|
46
|
+
useCachedSection(id)
|
|
47
|
+
const { recordingState, setRecordingState, removeRecordingState } =
|
|
48
|
+
useRecordingState(id)
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
// On mount, add recording state for this ID to context if needed
|
|
52
|
+
if (!recordingState) {
|
|
53
|
+
setRecordingState(recordingStates.default)
|
|
54
|
+
}
|
|
55
|
+
// On unnmount, remove recording state if not recording
|
|
56
|
+
return () => {
|
|
57
|
+
if (
|
|
58
|
+
recordingState &&
|
|
59
|
+
recordingState !== recordingStates.recording &&
|
|
60
|
+
recordingState !== recordingStates.pending
|
|
61
|
+
) {
|
|
62
|
+
removeRecordingState()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
66
|
+
|
|
67
|
+
const onRecordingStarted = useCallback(() => {
|
|
68
|
+
setRecordingState(recordingStates.recording)
|
|
69
|
+
}, [setRecordingState])
|
|
70
|
+
|
|
71
|
+
const onRecordingCompleted = useCallback(() => {
|
|
72
|
+
setRecordingState(recordingStates.default)
|
|
73
|
+
syncCachedSections()
|
|
74
|
+
}, [setRecordingState, syncCachedSections])
|
|
75
|
+
|
|
76
|
+
const onRecordingError = useCallback(
|
|
77
|
+
(error: Error) => {
|
|
78
|
+
console.error('Error during recording:', error)
|
|
79
|
+
setRecordingState(recordingStates.error)
|
|
80
|
+
},
|
|
81
|
+
[setRecordingState]
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const startRecording = useCallback(
|
|
85
|
+
({
|
|
86
|
+
recordingTimeoutDelay = 1000,
|
|
87
|
+
onStarted,
|
|
88
|
+
onCompleted,
|
|
89
|
+
onError,
|
|
90
|
+
}: CacheableSectionStartRecordingOptions = {}) => {
|
|
91
|
+
// This promise resolving means that the message to the service worker
|
|
92
|
+
// to start recording was successful. Waiting for resolution prevents
|
|
93
|
+
// unnecessarily rerendering the whole component in case of an error
|
|
94
|
+
return offlineInterface
|
|
95
|
+
.startRecording({
|
|
96
|
+
sectionId: id,
|
|
97
|
+
recordingTimeoutDelay,
|
|
98
|
+
onStarted: () => {
|
|
99
|
+
// Flush this state update synchronously so that the
|
|
100
|
+
// right recordingState is set before any other callbacks
|
|
101
|
+
flushSync(() => {
|
|
102
|
+
onRecordingStarted()
|
|
103
|
+
})
|
|
104
|
+
onStarted?.()
|
|
105
|
+
},
|
|
106
|
+
onCompleted: () => {
|
|
107
|
+
flushSync(() => {
|
|
108
|
+
onRecordingCompleted()
|
|
109
|
+
})
|
|
110
|
+
onCompleted?.()
|
|
111
|
+
},
|
|
112
|
+
onError: (error) => {
|
|
113
|
+
flushSync(() => {
|
|
114
|
+
onRecordingError(error)
|
|
115
|
+
})
|
|
116
|
+
onError?.(error)
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
.then(() => setRecordingState(recordingStates.pending))
|
|
120
|
+
},
|
|
121
|
+
[
|
|
122
|
+
id,
|
|
123
|
+
offlineInterface,
|
|
124
|
+
onRecordingCompleted,
|
|
125
|
+
onRecordingError,
|
|
126
|
+
onRecordingStarted,
|
|
127
|
+
setRecordingState,
|
|
128
|
+
]
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
// isCached, lastUpdated, remove: _could_ be accessed by useCachedSection,
|
|
132
|
+
// but provided through this hook for convenience
|
|
133
|
+
return useMemo(
|
|
134
|
+
() => ({
|
|
135
|
+
recordingState,
|
|
136
|
+
startRecording,
|
|
137
|
+
lastUpdated,
|
|
138
|
+
isCached,
|
|
139
|
+
remove,
|
|
140
|
+
}),
|
|
141
|
+
[recordingState, startRecording, lastUpdated, isCached, remove]
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface CacheableSectionProps {
|
|
146
|
+
id: string
|
|
147
|
+
loadingMask: JSX.Element
|
|
148
|
+
children: React.ReactNode
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Used to wrap the relevant component to be recorded and saved offline.
|
|
153
|
+
* Depending on the recording state of the section, this wrapper will
|
|
154
|
+
* render its children, not render its children while recording is pending,
|
|
155
|
+
* or RErerender the chilren to force data fetching to record by the service
|
|
156
|
+
* worker.
|
|
157
|
+
*
|
|
158
|
+
* During recording, a loading mask provided by props is also rendered that is
|
|
159
|
+
* intended to prevent other interaction with the app that might interfere
|
|
160
|
+
* with the recording process.
|
|
161
|
+
*/
|
|
162
|
+
export function CacheableSection({
|
|
163
|
+
id,
|
|
164
|
+
loadingMask,
|
|
165
|
+
children,
|
|
166
|
+
}: CacheableSectionProps): JSX.Element {
|
|
167
|
+
// Accesses recording state that useCacheableSection controls
|
|
168
|
+
const { recordingState } = useRecordingState(id)
|
|
169
|
+
|
|
170
|
+
// The following causes the component to reload in the event of a recording
|
|
171
|
+
// error; the state will be cleared next time recording moves to pending.
|
|
172
|
+
// It fixes a component getting stuck while rendered without data after
|
|
173
|
+
// failing a recording while offline.
|
|
174
|
+
// Errors can be handled in the `onError` callback to `startRecording`.
|
|
175
|
+
if (recordingState === recordingStates.error) {
|
|
176
|
+
return <>{children}</>
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Handling rendering with the following conditions prevents an unncessary
|
|
180
|
+
// rerender after successful recording
|
|
181
|
+
return (
|
|
182
|
+
<>
|
|
183
|
+
{recordingState === recordingStates.recording && loadingMask}
|
|
184
|
+
{recordingState !== recordingStates.pending && children}
|
|
185
|
+
</>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
CacheableSection.propTypes = {
|
|
190
|
+
id: PropTypes.string.isRequired,
|
|
191
|
+
children: PropTypes.node,
|
|
192
|
+
loadingMask: PropTypes.node,
|
|
193
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// IndexedDB names; should be the same as in @dhis2/pwa
|
|
2
|
+
export const SECTIONS_DB = 'sections-db'
|
|
3
|
+
export const SECTIONS_STORE = 'sections-store'
|
|
4
|
+
|
|
5
|
+
// Non-sensitive caches that can be kept:
|
|
6
|
+
const KEEPABLE_CACHES = [
|
|
7
|
+
/^workbox-precache/, // precached static assets
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
declare global {
|
|
11
|
+
interface IDBFactory {
|
|
12
|
+
databases(): Promise<[{ name: string; version: number }]>
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/*
|
|
17
|
+
* Clears the 'sections-db' IndexedDB if it exists. Designed to avoid opening
|
|
18
|
+
* a new DB if it doesn't exist yet. Firefox can't check if 'sections-db'
|
|
19
|
+
* exists, in which circumstance the IndexedDB is unaffected. It's inelegant
|
|
20
|
+
* but acceptable because the IndexedDB has no sensitive data (only metadata
|
|
21
|
+
* of recorded sections), and the OfflineInterface handles discrepancies
|
|
22
|
+
* between CacheStorage and IndexedDB.
|
|
23
|
+
*/
|
|
24
|
+
const clearDB = async (dbName: string): Promise<void> => {
|
|
25
|
+
if (!('databases' in indexedDB)) {
|
|
26
|
+
// FF does not have indexedDB.databases. For that, just clear caches,
|
|
27
|
+
// and offline interface will handle discrepancies in PWA apps.
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const dbs = await window.indexedDB.databases()
|
|
32
|
+
if (!dbs.some(({ name }) => name === dbName)) {
|
|
33
|
+
// Sections-db is not created; nothing to do here
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
// IndexedDB fun:
|
|
39
|
+
const openDBRequest = indexedDB.open(dbName)
|
|
40
|
+
openDBRequest.onsuccess = (e) => {
|
|
41
|
+
const db = (e.target as IDBOpenDBRequest).result
|
|
42
|
+
const tx = db.transaction(SECTIONS_STORE, 'readwrite')
|
|
43
|
+
// When the transaction completes is when the operation is done:
|
|
44
|
+
tx.oncomplete = () => resolve()
|
|
45
|
+
tx.onerror = (e) => reject((e.target as IDBRequest).error)
|
|
46
|
+
const os = tx.objectStore(SECTIONS_STORE)
|
|
47
|
+
const clearReq = os.clear()
|
|
48
|
+
clearReq.onerror = (e) => reject((e.target as IDBRequest).error)
|
|
49
|
+
}
|
|
50
|
+
openDBRequest.onerror = (e) => {
|
|
51
|
+
reject((e.target as IDBOpenDBRequest).error)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Used to clear caches and 'sections-db' IndexedDB when a user logs out or a
|
|
58
|
+
* different user logs in to prevent someone from accessing a different user's
|
|
59
|
+
* caches. Should be able to be used in a non-PWA app.
|
|
60
|
+
*/
|
|
61
|
+
export async function clearSensitiveCaches(
|
|
62
|
+
dbName: string = SECTIONS_DB
|
|
63
|
+
): Promise<any> {
|
|
64
|
+
console.debug('Clearing sensitive caches')
|
|
65
|
+
let cacheKeys
|
|
66
|
+
|
|
67
|
+
// caches.keys can fail in insecure contexts, see:
|
|
68
|
+
// https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage
|
|
69
|
+
try {
|
|
70
|
+
cacheKeys = await caches.keys()
|
|
71
|
+
} catch (e) {
|
|
72
|
+
// Return false since no caches have been cleared
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return Promise.all([
|
|
77
|
+
// (Resolves to 'false' because this can't detect if anything was deleted):
|
|
78
|
+
clearDB(dbName).then(() => false),
|
|
79
|
+
// Remove caches if not in keepable list
|
|
80
|
+
...cacheKeys.map((key) => {
|
|
81
|
+
if (!KEEPABLE_CACHES.some((pattern) => pattern.test(key))) {
|
|
82
|
+
return caches.delete(key)
|
|
83
|
+
}
|
|
84
|
+
return false
|
|
85
|
+
}),
|
|
86
|
+
]).then((responses) => {
|
|
87
|
+
// Return true if any caches have been cleared
|
|
88
|
+
// (caches.delete() returns true if a cache is deleted successfully)
|
|
89
|
+
// PWA apps can reload to restore their app shell cache
|
|
90
|
+
return responses.some((response) => response)
|
|
91
|
+
})
|
|
92
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const shouldLog = localStorage.getItem('dhis2.debugConnectionStatus')
|
|
2
|
+
if (shouldLog) {
|
|
3
|
+
console.log(
|
|
4
|
+
'Logging for dhis2ConnectionStatus is enabled. Remove the `dhis2.debugConnectionStatus` item in localStorage to disable logging.'
|
|
5
|
+
)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* This can be used to log info if the `dhis2.debugConnectionStatus` value
|
|
10
|
+
* in localStorage is set to a truthy value during development.
|
|
11
|
+
* Set the value manually and refresh the page to see the logs.
|
|
12
|
+
*
|
|
13
|
+
* The behavior of the connection status can be quite hard to inspect without
|
|
14
|
+
* logs, but the logs are quite chatty and should be omitted normally.
|
|
15
|
+
*/
|
|
16
|
+
export function devDebugLog(...args: any[]) {
|
|
17
|
+
if (shouldLog) {
|
|
18
|
+
console.log(...args)
|
|
19
|
+
}
|
|
20
|
+
}
|