@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.
Files changed (37) hide show
  1. package/.gitignore +5 -0
  2. package/build/cjs/declarations.d.js +1 -0
  3. package/build/es/declarations.d.js +1 -0
  4. package/d2.config.js +9 -0
  5. package/jest.config.js +14 -0
  6. package/package.json +3 -3
  7. package/src/__tests__/integration.test.tsx +341 -0
  8. package/src/declarations.d.ts +1 -0
  9. package/src/index.ts +12 -0
  10. package/src/lib/__tests__/cacheable-section-state.test.tsx +45 -0
  11. package/src/lib/__tests__/clear-sensitive-caches.test.ts +182 -0
  12. package/src/lib/__tests__/network-status.test.tsx +496 -0
  13. package/src/lib/__tests__/offline-provider.test.tsx +116 -0
  14. package/src/lib/__tests__/use-cacheable-section.test.tsx +280 -0
  15. package/src/lib/__tests__/use-online-status-message.test.tsx +27 -0
  16. package/src/lib/cacheable-section-state.tsx +269 -0
  17. package/src/lib/cacheable-section.tsx +193 -0
  18. package/src/lib/clear-sensitive-caches.ts +92 -0
  19. package/src/lib/dhis2-connection-status/dev-debug-log.ts +20 -0
  20. package/src/lib/dhis2-connection-status/dhis2-connection-status.test.tsx +947 -0
  21. package/src/lib/dhis2-connection-status/dhis2-connection-status.tsx +241 -0
  22. package/src/lib/dhis2-connection-status/index.ts +4 -0
  23. package/src/lib/dhis2-connection-status/is-ping-available.test.ts +32 -0
  24. package/src/lib/dhis2-connection-status/is-ping-available.ts +31 -0
  25. package/src/lib/dhis2-connection-status/smart-interval.ts +206 -0
  26. package/src/lib/dhis2-connection-status/use-ping-query.ts +14 -0
  27. package/src/lib/global-state-service.tsx +110 -0
  28. package/src/lib/network-status.ts +80 -0
  29. package/src/lib/offline-interface.tsx +57 -0
  30. package/src/lib/offline-provider.tsx +43 -0
  31. package/src/lib/online-status-message.tsx +47 -0
  32. package/src/setupRTL.ts +1 -0
  33. package/src/types.ts +66 -0
  34. package/src/utils/__tests__/render-counter.test.tsx +45 -0
  35. package/src/utils/render-counter.tsx +22 -0
  36. package/src/utils/test-mocks.ts +47 -0
  37. 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
+ }