@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.
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,280 @@
1
+ import { renderHook, act, waitFor } from '@testing-library/react'
2
+ import React, { FC, PropsWithChildren } from 'react'
3
+ import {
4
+ errorRecordingMock,
5
+ failedMessageRecordingMock,
6
+ mockOfflineInterface,
7
+ } from '../../utils/test-mocks'
8
+ import { useCacheableSection } from '../cacheable-section'
9
+ import { OfflineProvider } from '../offline-provider'
10
+
11
+ // Suppress 'act' warning for these tests
12
+ const originalError = console.error
13
+ beforeEach(() => {
14
+ jest.spyOn(console, 'error').mockImplementation((...args) => {
15
+ const pattern =
16
+ /Warning: An update to .* inside a test was not wrapped in act/
17
+ if (typeof args[0] === 'string' && pattern.test(args[0])) {
18
+ return
19
+ }
20
+ return originalError.call(console, ...args)
21
+ })
22
+ })
23
+
24
+ afterEach(() => {
25
+ jest.clearAllMocks()
26
+ // This syntax appeases typescript:
27
+ ;(console.error as jest.Mock).mockRestore()
28
+ })
29
+
30
+ it('renders in the default state initially', () => {
31
+ const wrapper: FC<PropsWithChildren> = ({ children }) => (
32
+ <OfflineProvider offlineInterface={mockOfflineInterface}>
33
+ {children}
34
+ </OfflineProvider>
35
+ )
36
+
37
+ const { result } = renderHook(() => useCacheableSection('one'), {
38
+ wrapper,
39
+ })
40
+
41
+ expect(result.current.recordingState).toBe('default')
42
+ expect(result.current.isCached).toBe(false)
43
+ expect(result.current.lastUpdated).toBeUndefined()
44
+ })
45
+
46
+ it('has stable references', () => {
47
+ const wrapper: FC<PropsWithChildren> = ({ children }) => (
48
+ <OfflineProvider offlineInterface={mockOfflineInterface}>
49
+ {children}
50
+ </OfflineProvider>
51
+ )
52
+ const { result, rerender } = renderHook(() => useCacheableSection('one'), {
53
+ wrapper,
54
+ })
55
+
56
+ const origRecordingState = result.current.recordingState
57
+ const origStartRecording = result.current.startRecording
58
+ const origLastUpdated = result.current.lastUpdated
59
+ const origIsCached = result.current.isCached
60
+ const origRemove = result.current.remove
61
+
62
+ rerender()
63
+
64
+ expect(result.current.recordingState).toBe(origRecordingState)
65
+ expect(result.current.startRecording).toBe(origStartRecording)
66
+ expect(result.current.lastUpdated).toBe(origLastUpdated)
67
+ expect(result.current.isCached).toBe(origIsCached)
68
+ expect(result.current.remove).toBe(origRemove)
69
+ })
70
+
71
+ it('handles a successful recording', async (done) => {
72
+ const [sectionId, timeoutDelay] = ['one', 1234]
73
+ const recordingSuccessOfflineInterface = {
74
+ ...mockOfflineInterface,
75
+ getCachedSections: jest
76
+ .fn()
77
+ .mockResolvedValueOnce([])
78
+ .mockResolvedValueOnce([
79
+ { sectionId: sectionId, lastUpdated: new Date() },
80
+ ]),
81
+ }
82
+ const wrapper: FC<PropsWithChildren> = ({ children }) => (
83
+ <OfflineProvider offlineInterface={recordingSuccessOfflineInterface}>
84
+ {children}
85
+ </OfflineProvider>
86
+ )
87
+ const { result } = renderHook(() => useCacheableSection(sectionId), {
88
+ wrapper,
89
+ })
90
+
91
+ const assertRecordingStarted = () => {
92
+ expect(result.current.recordingState).toBe('recording')
93
+ }
94
+ const assertRecordingCompleted = async () => {
95
+ expect(result.current.recordingState).toBe('default')
96
+
97
+ // Test that 'isCached' gets updated
98
+ expect(
99
+ recordingSuccessOfflineInterface.getCachedSections
100
+ ).toBeCalledTimes(2)
101
+ // Recording states are updated synchronously, but getting isCached
102
+ // state is asynchronous -- need to wait for that here.
103
+ // An assertion is not used as the waitFor condition because it may skew
104
+ // the total number assertions in this test if it needs to retry. Number
105
+ // of assertions is checked at the bottom of this test to make sure both
106
+ // of these callbacks are called.
107
+ await waitFor(() => result.current.isCached === true)
108
+ expect(result.current.isCached).toBe(true)
109
+ expect(result.current.lastUpdated).toBeInstanceOf(Date)
110
+
111
+ // If this cb is not called, test should time out and fail
112
+ done()
113
+ }
114
+
115
+ await act(async () => {
116
+ await result.current.startRecording({
117
+ onStarted: assertRecordingStarted,
118
+ onCompleted: assertRecordingCompleted,
119
+ recordingTimeoutDelay: timeoutDelay,
120
+ })
121
+ })
122
+
123
+ // At this stage, recording should be 'pending'
124
+ expect(result.current.recordingState).toBe('pending')
125
+
126
+ // Check correct options sent to offline interface
127
+ const options = mockOfflineInterface.startRecording.mock.calls[0][0]
128
+ expect(options.sectionId).toBe(sectionId)
129
+ expect(options.recordingTimeoutDelay).toBe(timeoutDelay)
130
+ expect(typeof options.onStarted).toBe('function')
131
+ expect(typeof options.onCompleted).toBe('function')
132
+ expect(typeof options.onError).toBe('function')
133
+
134
+ // Make sure all async assertions are called
135
+ expect.assertions(11)
136
+ })
137
+
138
+ it('handles a recording that encounters an error', async (done) => {
139
+ // Suppress the expected error from console (in addition to 'act' warning)
140
+ jest.spyOn(console, 'error').mockImplementation((...args) => {
141
+ const actPattern =
142
+ /Warning: An update to .* inside a test was not wrapped in act/
143
+ const errPattern = /Error during recording/
144
+ const matchesPattern =
145
+ actPattern.test(args[0]) || errPattern.test(args[0])
146
+ if (typeof args[0] === 'string' && matchesPattern) {
147
+ return
148
+ }
149
+ return originalError.call(console, ...args)
150
+ })
151
+ const recordingErrorOfflineInterface = {
152
+ ...mockOfflineInterface,
153
+ startRecording: errorRecordingMock,
154
+ }
155
+ const wrapper: FC<PropsWithChildren> = ({ children }) => (
156
+ <OfflineProvider offlineInterface={recordingErrorOfflineInterface}>
157
+ {children}
158
+ </OfflineProvider>
159
+ )
160
+ const { result } = renderHook(() => useCacheableSection('one'), {
161
+ wrapper,
162
+ })
163
+
164
+ const assertRecordingStarted = () => {
165
+ expect(result.current.recordingState).toBe('recording')
166
+ }
167
+ const assertRecordingError = (error: Error) => {
168
+ expect(result.current.recordingState).toBe('error')
169
+ expect(error.message).toMatch(/test err/) // see errorRecordingMock
170
+ expect(console.error).toHaveBeenCalledWith(
171
+ 'Error during recording:',
172
+ error
173
+ )
174
+
175
+ // Expect only one call, from initialization:
176
+ expect(mockOfflineInterface.getCachedSections).toBeCalledTimes(1)
177
+
178
+ // If this cb is not called, test should time out and fail
179
+ done()
180
+ }
181
+
182
+ await act(async () => {
183
+ await result.current.startRecording({
184
+ onStarted: assertRecordingStarted,
185
+ onError: assertRecordingError,
186
+ })
187
+ })
188
+
189
+ // At this stage, recording should be 'pending'
190
+ expect(result.current.recordingState).toBe('pending')
191
+
192
+ // Make sure all async assertions are called
193
+ expect.assertions(6)
194
+ })
195
+
196
+ it('handles an error starting the recording', async () => {
197
+ const messageErrorOfflineInterface = {
198
+ ...mockOfflineInterface,
199
+ startRecording: failedMessageRecordingMock,
200
+ }
201
+ const wrapper: FC<PropsWithChildren> = ({ children }) => (
202
+ <OfflineProvider offlineInterface={messageErrorOfflineInterface}>
203
+ {children}
204
+ </OfflineProvider>
205
+ )
206
+ const { result } = renderHook(() => useCacheableSection('err'), {
207
+ wrapper,
208
+ })
209
+
210
+ await expect(result.current.startRecording()).rejects.toThrow(
211
+ 'Failed message' // from failedMessageRecordingMock
212
+ )
213
+ })
214
+
215
+ it('handles remove and updates sections', async () => {
216
+ const sectionId = 'one'
217
+ const sectionOpsOfflineInterface = {
218
+ ...mockOfflineInterface,
219
+ getCachedSections: jest
220
+ .fn()
221
+ .mockResolvedValueOnce([
222
+ { sectionId: sectionId, lastUpdated: new Date() },
223
+ ])
224
+ .mockResolvedValueOnce([]),
225
+ }
226
+ const wrapper: FC<PropsWithChildren> = ({ children }) => (
227
+ <OfflineProvider offlineInterface={sectionOpsOfflineInterface}>
228
+ {children}
229
+ </OfflineProvider>
230
+ )
231
+ const { result } = renderHook(() => useCacheableSection(sectionId), {
232
+ wrapper,
233
+ })
234
+
235
+ // Wait for state to sync with indexedDB
236
+ await waitFor(() => expect(result.current.isCached).toBe(true))
237
+
238
+ let success
239
+ await act(async () => {
240
+ success = await result.current.remove()
241
+ })
242
+
243
+ expect(success).toBe(true)
244
+ // Test that 'isCached' gets updated
245
+ expect(sectionOpsOfflineInterface.getCachedSections).toBeCalledTimes(2)
246
+ await waitFor(() => expect(result.current.isCached).toBe(false))
247
+ expect(result.current.isCached).toBe(false)
248
+ expect(result.current.lastUpdated).toBeUndefined()
249
+ })
250
+
251
+ it('handles a change in ID', async () => {
252
+ const idChangeOfflineInterface = {
253
+ ...mockOfflineInterface,
254
+ getCachedSections: jest
255
+ .fn()
256
+ .mockResolvedValue([
257
+ { sectionId: 'id-one', lastUpdated: new Date() },
258
+ ]),
259
+ }
260
+ const wrapper: FC<PropsWithChildren> = ({ children }) => (
261
+ <OfflineProvider offlineInterface={idChangeOfflineInterface}>
262
+ {children}
263
+ </OfflineProvider>
264
+ )
265
+ const { result, rerender } = renderHook(
266
+ (id: any) => useCacheableSection(id),
267
+ { wrapper, initialProps: 'id-one' }
268
+ )
269
+
270
+ // Wait for state to sync with indexedDB
271
+ await waitFor(() => expect(result.current.isCached).toBe(true))
272
+
273
+ rerender('id-two')
274
+
275
+ // Test that 'isCached' gets updated
276
+ // expect(idChangeOfflineInterface.getCachedSections).toBeCalledTimes(2)
277
+ await waitFor(() => expect(result.current.isCached).toBe(false))
278
+ expect(result.current.isCached).toBe(false)
279
+ expect(result.current.lastUpdated).toBeUndefined()
280
+ })
@@ -0,0 +1,27 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import React, { FC, ReactNode } from 'react'
3
+ import { mockOfflineInterface } from '../../utils/test-mocks'
4
+ import { OfflineProvider } from '../offline-provider'
5
+ import { useOnlineStatusMessage } from '../online-status-message'
6
+
7
+ describe('useOnlineStatusMessage', () => {
8
+ it('should allow the online status to be updated ', () => {
9
+ const wrapper: FC<{ children?: ReactNode }> = ({ children }) => (
10
+ <OfflineProvider offlineInterface={mockOfflineInterface}>
11
+ {children}
12
+ </OfflineProvider>
13
+ )
14
+
15
+ const { result } = renderHook(() => useOnlineStatusMessage(), {
16
+ wrapper,
17
+ })
18
+
19
+ expect(result.current.onlineStatusMessage).toBeUndefined()
20
+
21
+ act(() => {
22
+ result.current.setOnlineStatusMessage('8 offline events')
23
+ })
24
+
25
+ expect(result.current.onlineStatusMessage).toEqual('8 offline events')
26
+ })
27
+ })
@@ -0,0 +1,269 @@
1
+ import PropTypes from 'prop-types'
2
+ import React, { useEffect, useCallback, useMemo } from 'react'
3
+ import {
4
+ GlobalStateStore,
5
+ GlobalStateStoreMutationCreator,
6
+ IndexedDBCachedSection,
7
+ RecordingState,
8
+ } from '../types'
9
+ import {
10
+ createStore,
11
+ useGlobalState,
12
+ useGlobalStateMutation,
13
+ GlobalStateProvider,
14
+ } from './global-state-service'
15
+ import { useOfflineInterface } from './offline-interface'
16
+
17
+ // Functions in here use the global state service to manage cacheable section
18
+ // state in a performant way
19
+
20
+ interface CachedSectionsById {
21
+ [index: string]: { lastUpdated: Date }
22
+ }
23
+
24
+ /**
25
+ * Helper that transforms an array of cached section objects from the IndexedDB
26
+ * into an object of values keyed by section ID
27
+ *
28
+ * @param {Array} list - An array of section objects
29
+ * @returns {Object} An object of sections, keyed by ID
30
+ */
31
+ function getSectionsById(
32
+ sectionsArray: IndexedDBCachedSection[]
33
+ ): CachedSectionsById {
34
+ return sectionsArray.reduce(
35
+ (result, { sectionId, lastUpdated }) => ({
36
+ ...result,
37
+ [sectionId]: { lastUpdated },
38
+ }),
39
+ {}
40
+ )
41
+ }
42
+
43
+ /**
44
+ * Create a store for Cacheable Section state.
45
+ * Expected to be used in app adapter
46
+ */
47
+ export function createCacheableSectionStore(): GlobalStateStore {
48
+ const initialState = { recordingStates: {}, cachedSections: {} }
49
+ return createStore(initialState)
50
+ }
51
+
52
+ /**
53
+ * Helper hook that returns a value that will persist between renders but makes
54
+ * sure to only set its initial state once.
55
+ * See https://gist.github.com/amcgee/42bb2fa6d5f79e607f00e6dccc733482
56
+ */
57
+ function useConst<Type>(factory: () => Type): Type {
58
+ const ref = React.useRef<Type | null>(null)
59
+ if (ref.current === null) {
60
+ ref.current = factory()
61
+ }
62
+ return ref.current
63
+ }
64
+
65
+ /**
66
+ * Provides context for a global state context which will track cached
67
+ * sections' status and cacheable sections' recording states, which will
68
+ * determine how that component will render. The provider will be a part of
69
+ * the OfflineProvider.
70
+ */
71
+ export function CacheableSectionProvider({
72
+ children,
73
+ }: {
74
+ children: React.ReactNode
75
+ }): JSX.Element {
76
+ const offlineInterface = useOfflineInterface()
77
+ const store = useConst(createCacheableSectionStore)
78
+
79
+ // On load, get sections and add to store
80
+ useEffect(() => {
81
+ if (offlineInterface) {
82
+ offlineInterface.getCachedSections().then((sections) => {
83
+ store.mutate((state) => ({
84
+ ...state,
85
+ cachedSections: getSectionsById(sections),
86
+ }))
87
+ })
88
+ }
89
+ }, [store, offlineInterface])
90
+
91
+ return <GlobalStateProvider store={store}>{children}</GlobalStateProvider>
92
+ }
93
+ CacheableSectionProvider.propTypes = {
94
+ children: PropTypes.node,
95
+ }
96
+
97
+ interface RecordingStateControls {
98
+ recordingState: RecordingState
99
+ setRecordingState: (newState: RecordingState) => void
100
+ removeRecordingState: () => void
101
+ }
102
+
103
+ /**
104
+ * Uses an optimized global state to manage 'recording state' values without
105
+ * unnecessarily rerendering all consuming components
106
+ *
107
+ * @param {String} id - ID of the cacheable section to track
108
+ * @returns {Object} { recordingState: String, setRecordingState: Function, removeRecordingState: Function}
109
+ */
110
+ export function useRecordingState(id: string): RecordingStateControls {
111
+ const recordingStateSelector = useCallback(
112
+ (state: any) => state.recordingStates[id],
113
+ [id]
114
+ )
115
+ const [recordingState] = useGlobalState(recordingStateSelector)
116
+
117
+ const setRecordingStateMutationCreator = useCallback<
118
+ GlobalStateStoreMutationCreator<RecordingState>
119
+ >(
120
+ (newState) => (state: any) => ({
121
+ ...state,
122
+ recordingStates: { ...state.recordingStates, [id]: newState },
123
+ }),
124
+ [id]
125
+ )
126
+ const setRecordingState = useGlobalStateMutation(
127
+ setRecordingStateMutationCreator
128
+ )
129
+
130
+ const removeRecordingStateMutationCreator = useCallback(
131
+ () => (state: any) => {
132
+ const recordingStates = { ...state.recordingStates }
133
+ delete recordingStates[id]
134
+ return { ...state, recordingStates }
135
+ },
136
+ [id]
137
+ )
138
+ const removeRecordingState = useGlobalStateMutation(
139
+ removeRecordingStateMutationCreator
140
+ )
141
+
142
+ return useMemo(
143
+ () => ({
144
+ recordingState,
145
+ setRecordingState,
146
+ removeRecordingState,
147
+ }),
148
+ [recordingState, setRecordingState, removeRecordingState]
149
+ )
150
+ }
151
+
152
+ /**
153
+ * Returns a function that syncs cached sections in the global state
154
+ * with IndexedDB, so that IndexedDB is the single source of truth
155
+ *
156
+ * @returns {Function} syncCachedSections
157
+ */
158
+ function useSyncCachedSections() {
159
+ const offlineInterface = useOfflineInterface()
160
+
161
+ const setCachedSectionsMutationCreator = useCallback<
162
+ GlobalStateStoreMutationCreator<CachedSectionsById>
163
+ >(
164
+ (cachedSections) => (state: any) => ({
165
+ ...state,
166
+ cachedSections,
167
+ }),
168
+ []
169
+ )
170
+ const setCachedSections = useGlobalStateMutation(
171
+ setCachedSectionsMutationCreator
172
+ )
173
+
174
+ return useCallback(async () => {
175
+ const sections = await offlineInterface.getCachedSections()
176
+ setCachedSections(getSectionsById(sections))
177
+ }, [offlineInterface, setCachedSections])
178
+ }
179
+
180
+ interface CachedSectionsControls {
181
+ cachedSections: CachedSectionsById
182
+ removeById: (id: string) => Promise<boolean>
183
+ syncCachedSections: () => Promise<void>
184
+ }
185
+
186
+ /**
187
+ * Uses global state to manage an object of cached sections' statuses
188
+ *
189
+ * @returns {Object} { cachedSections: Object, removeSection: Function }
190
+ */
191
+ export function useCachedSections(): CachedSectionsControls {
192
+ const [cachedSections] = useGlobalState((state) => state.cachedSections)
193
+ const syncCachedSections = useSyncCachedSections()
194
+ const offlineInterface = useOfflineInterface()
195
+
196
+ /**
197
+ * Uses offline interface to remove a section from IndexedDB and Cache
198
+ * Storage.
199
+ *
200
+ * Returns a promise that resolves to `true` if a section is found and
201
+ * deleted, or `false` if asection with the specified ID does not exist.
202
+ */
203
+ const removeById = useCallback(
204
+ async (id: string) => {
205
+ const success = await offlineInterface.removeSection(id)
206
+ if (success) {
207
+ await syncCachedSections()
208
+ }
209
+ return success
210
+ },
211
+ [offlineInterface, syncCachedSections]
212
+ )
213
+
214
+ return useMemo(
215
+ () => ({
216
+ cachedSections,
217
+ removeById,
218
+ syncCachedSections,
219
+ }),
220
+ [cachedSections, removeById, syncCachedSections]
221
+ )
222
+ }
223
+
224
+ interface CachedSectionControls {
225
+ lastUpdated: Date
226
+ isCached: boolean
227
+ remove: () => Promise<boolean>
228
+ syncCachedSections: () => Promise<void>
229
+ }
230
+
231
+ /**
232
+ * Uses global state to manage the cached status of just one section, which
233
+ * prevents unnecessary rerenders of consuming components
234
+ *
235
+ * @param {String} id
236
+ * @returns {Object} { lastUpdated: Date, remove: Function }
237
+ */
238
+ export function useCachedSection(id: string): CachedSectionControls {
239
+ const [status] = useGlobalState((state) => state.cachedSections[id])
240
+ const syncCachedSections = useSyncCachedSections()
241
+ const offlineInterface = useOfflineInterface()
242
+
243
+ const lastUpdated = status && status.lastUpdated
244
+
245
+ /**
246
+ * Uses offline interface to remove a section from IndexedDB and Cache
247
+ * Storage.
248
+ *
249
+ * Returns `true` if a section is found and deleted, or `false` if a
250
+ * section with the specified ID does not exist.
251
+ */
252
+ const remove = useCallback(async () => {
253
+ const success = await offlineInterface.removeSection(id)
254
+ if (success) {
255
+ await syncCachedSections()
256
+ }
257
+ return success
258
+ }, [offlineInterface, id, syncCachedSections])
259
+
260
+ return useMemo(
261
+ () => ({
262
+ lastUpdated,
263
+ isCached: !!lastUpdated,
264
+ remove,
265
+ syncCachedSections,
266
+ }),
267
+ [lastUpdated, remove, syncCachedSections]
268
+ )
269
+ }