@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,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
|
+
}
|