@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,80 @@
|
|
|
1
|
+
import debounce from 'lodash/debounce'
|
|
2
|
+
import { useState, useEffect, useCallback, useMemo } from 'react'
|
|
3
|
+
|
|
4
|
+
type milliseconds = number
|
|
5
|
+
interface NetworkStatusOptions {
|
|
6
|
+
debounceDelay?: milliseconds
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface NetworkStatus {
|
|
10
|
+
online: boolean
|
|
11
|
+
offline: boolean
|
|
12
|
+
lastOnline: Date | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const lastOnlineKey = 'dhis2.lastOnline'
|
|
16
|
+
|
|
17
|
+
// TODO: Add option to periodically ping server to check online status.
|
|
18
|
+
// TODO: Add logic to return a variable indicating unstable connection.
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns the browser's online status. Updates in response to 'online' and
|
|
22
|
+
* 'offline' events. By default, debounces updates to once every second to
|
|
23
|
+
* avoid UI flicker, but that delay can be configured with the
|
|
24
|
+
* `options.debounceDelay` param.
|
|
25
|
+
*
|
|
26
|
+
* On state change, updates the `dhis2.lastOnline` property in local storage
|
|
27
|
+
* for consuming apps to format and display. Returns `lastOnline` as `null` if
|
|
28
|
+
* online or as a Date if offline.
|
|
29
|
+
*
|
|
30
|
+
* @param {Object} [options]
|
|
31
|
+
* @param {Number} [options.debounceDelay] - Timeout delay to debounce updates, in ms
|
|
32
|
+
* @returns {Object} `{ online: boolean, offline: boolean, lastOnline: Date | null }`
|
|
33
|
+
*/
|
|
34
|
+
export function useNetworkStatus(
|
|
35
|
+
options: NetworkStatusOptions = {}
|
|
36
|
+
): NetworkStatus {
|
|
37
|
+
// initialize state to `navigator.onLine` value
|
|
38
|
+
const [online, setOnline] = useState(navigator.onLine)
|
|
39
|
+
|
|
40
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
41
|
+
const updateState = useCallback(
|
|
42
|
+
debounce(({ type }: Event) => {
|
|
43
|
+
if (type === 'online') {
|
|
44
|
+
setOnline(true)
|
|
45
|
+
} else if (type === 'offline') {
|
|
46
|
+
if (online || !localStorage.getItem(lastOnlineKey)) {
|
|
47
|
+
localStorage.setItem(
|
|
48
|
+
lastOnlineKey,
|
|
49
|
+
new Date(Date.now()).toUTCString()
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
setOnline(false)
|
|
53
|
+
}
|
|
54
|
+
}, options.debounceDelay ?? 1000),
|
|
55
|
+
[options.debounceDelay]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// on 'online' or 'offline' events, set state
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
window.addEventListener('online', updateState)
|
|
61
|
+
window.addEventListener('offline', updateState)
|
|
62
|
+
return () => {
|
|
63
|
+
updateState.flush()
|
|
64
|
+
window.removeEventListener('online', updateState)
|
|
65
|
+
window.removeEventListener('offline', updateState)
|
|
66
|
+
}
|
|
67
|
+
}, [updateState])
|
|
68
|
+
|
|
69
|
+
// Only fetch if `online === false` as local storage is synchronous and disk-based
|
|
70
|
+
const lastOnline = useMemo(
|
|
71
|
+
() => !online && localStorage.getItem(lastOnlineKey),
|
|
72
|
+
[online]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
online,
|
|
77
|
+
offline: !online,
|
|
78
|
+
lastOnline: lastOnline ? new Date(lastOnline) : null,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import PropTypes from 'prop-types'
|
|
2
|
+
import React, { createContext, useContext } from 'react'
|
|
3
|
+
import { OfflineInterface } from '../types'
|
|
4
|
+
|
|
5
|
+
// This is to prevent 'offlineInterface could be null' type-checking errors
|
|
6
|
+
const noopOfflineInterface: OfflineInterface = {
|
|
7
|
+
pwaEnabled: false,
|
|
8
|
+
latestIsConnected: false,
|
|
9
|
+
subscribeToDhis2ConnectionStatus: () => () => undefined,
|
|
10
|
+
startRecording: async () => undefined,
|
|
11
|
+
getCachedSections: async () => [],
|
|
12
|
+
removeSection: async () => false,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const OfflineInterfaceContext =
|
|
16
|
+
createContext<OfflineInterface>(noopOfflineInterface)
|
|
17
|
+
|
|
18
|
+
interface OfflineInterfaceProviderInput {
|
|
19
|
+
offlineInterface: OfflineInterface
|
|
20
|
+
children: React.ReactNode
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Receives an OfflineInterface instance as a prop (presumably from the app
|
|
25
|
+
* adapter) and provides it as context for other offline tools.
|
|
26
|
+
*
|
|
27
|
+
* On mount, it initializes the offline interface, which (among other things)
|
|
28
|
+
* checks for service worker updates and, if updates are ready, prompts the
|
|
29
|
+
* user with an alert to skip waiting and reload the page to use new content.
|
|
30
|
+
*/
|
|
31
|
+
export function OfflineInterfaceProvider({
|
|
32
|
+
offlineInterface,
|
|
33
|
+
children,
|
|
34
|
+
}: OfflineInterfaceProviderInput): JSX.Element {
|
|
35
|
+
return (
|
|
36
|
+
<OfflineInterfaceContext.Provider value={offlineInterface}>
|
|
37
|
+
{children}
|
|
38
|
+
</OfflineInterfaceContext.Provider>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
OfflineInterfaceProvider.propTypes = {
|
|
43
|
+
children: PropTypes.node,
|
|
44
|
+
offlineInterface: PropTypes.shape({ init: PropTypes.func }),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function useOfflineInterface(): OfflineInterface {
|
|
48
|
+
const offlineInterface = useContext(OfflineInterfaceContext)
|
|
49
|
+
|
|
50
|
+
if (offlineInterface === undefined) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
'Offline interface context not found. If this app is using the app platform, make sure `pwa: { enabled: true }` is in d2.config.js. If this is not a platform app, make sure your app is wrapped with an app-runtime <Provider> or an <OfflineProvider> from app-service-offline.'
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return offlineInterface
|
|
57
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import PropTypes from 'prop-types'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { OfflineInterface } from '../types'
|
|
4
|
+
import { CacheableSectionProvider } from './cacheable-section-state'
|
|
5
|
+
import { Dhis2ConnectionStatusProvider } from './dhis2-connection-status'
|
|
6
|
+
import { OfflineInterfaceProvider } from './offline-interface'
|
|
7
|
+
import { OnlineStatusMessageProvider } from './online-status-message'
|
|
8
|
+
|
|
9
|
+
interface OfflineProviderInput {
|
|
10
|
+
offlineInterface?: OfflineInterface
|
|
11
|
+
children?: React.ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A context provider for all the relevant offline contexts */
|
|
15
|
+
export function OfflineProvider({
|
|
16
|
+
offlineInterface,
|
|
17
|
+
children,
|
|
18
|
+
}: OfflineProviderInput): JSX.Element {
|
|
19
|
+
// If an offline interface is not provided, or if one is provided and PWA
|
|
20
|
+
// is not enabled, skip adding context providers
|
|
21
|
+
if (!offlineInterface || !offlineInterface.pwaEnabled) {
|
|
22
|
+
return <>{children}</>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<OfflineInterfaceProvider offlineInterface={offlineInterface}>
|
|
27
|
+
<Dhis2ConnectionStatusProvider>
|
|
28
|
+
<CacheableSectionProvider>
|
|
29
|
+
<OnlineStatusMessageProvider>
|
|
30
|
+
{children}
|
|
31
|
+
</OnlineStatusMessageProvider>
|
|
32
|
+
</CacheableSectionProvider>
|
|
33
|
+
</Dhis2ConnectionStatusProvider>
|
|
34
|
+
</OfflineInterfaceProvider>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
OfflineProvider.propTypes = {
|
|
39
|
+
children: PropTypes.node,
|
|
40
|
+
offlineInterface: PropTypes.shape({
|
|
41
|
+
pwaEnabled: PropTypes.bool,
|
|
42
|
+
}),
|
|
43
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React, { ReactElement, ReactNode, useContext, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
type SetOnlineStatusMessage = (message: ReactNode) => void
|
|
4
|
+
|
|
5
|
+
// 'get' and 'set' contexts are separated so 'setter' consumers that don't
|
|
6
|
+
// actually need the value don't have to rerender when the value changes:
|
|
7
|
+
const OnlineStatusMessageValueContext =
|
|
8
|
+
React.createContext<ReactNode>(undefined)
|
|
9
|
+
const SetOnlineStatusMessageContext =
|
|
10
|
+
React.createContext<SetOnlineStatusMessage>(() => undefined)
|
|
11
|
+
|
|
12
|
+
export const OnlineStatusMessageProvider = ({
|
|
13
|
+
children,
|
|
14
|
+
}: {
|
|
15
|
+
children: ReactNode
|
|
16
|
+
}): ReactElement => {
|
|
17
|
+
const [onlineStatusMessage, setOnlineStatusMessage] = useState<ReactNode>() // note: not undefined
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<OnlineStatusMessageValueContext.Provider value={onlineStatusMessage}>
|
|
21
|
+
<SetOnlineStatusMessageContext.Provider
|
|
22
|
+
value={setOnlineStatusMessage}
|
|
23
|
+
>
|
|
24
|
+
{children}
|
|
25
|
+
</SetOnlineStatusMessageContext.Provider>
|
|
26
|
+
</OnlineStatusMessageValueContext.Provider>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const useOnlineStatusMessageValue = () => {
|
|
31
|
+
return useContext(OnlineStatusMessageValueContext)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const useSetOnlineStatusMessage = () => {
|
|
35
|
+
return useContext(SetOnlineStatusMessageContext)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// combination of both getter and setter (also provides backward compatability)
|
|
39
|
+
export const useOnlineStatusMessage = () => {
|
|
40
|
+
const onlineStatusMessage = useOnlineStatusMessageValue()
|
|
41
|
+
const setOnlineStatusMessage = useSetOnlineStatusMessage()
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
onlineStatusMessage,
|
|
45
|
+
setOnlineStatusMessage,
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/setupRTL.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom'
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Cacheable Section types
|
|
2
|
+
|
|
3
|
+
export type RecordingState = 'default' | 'pending' | 'error' | 'recording'
|
|
4
|
+
|
|
5
|
+
// Global state types
|
|
6
|
+
|
|
7
|
+
// This is the mutation that gets executed by store.mutate. Should return
|
|
8
|
+
// new state
|
|
9
|
+
export interface GlobalStateStoreMutation {
|
|
10
|
+
(state: any): any
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Uses generic <Type> because global state mutation will also receive
|
|
14
|
+
// the same parameters
|
|
15
|
+
export interface GlobalStateStoreMutationCreator<Type> {
|
|
16
|
+
(...args: Type[]): GlobalStateStoreMutation
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// This is the function returned by useGlobalStateMutation;
|
|
20
|
+
// it triggers a GlobalStateStoreMutation
|
|
21
|
+
export interface GlobalStateMutation<Type> {
|
|
22
|
+
(...args: Type[]): void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GlobalStateStoreMutateMethod {
|
|
26
|
+
(mutation: GlobalStateStoreMutation): void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GlobalStateStore {
|
|
30
|
+
getState: () => any
|
|
31
|
+
subscribe: (callback: (state: any) => void) => void
|
|
32
|
+
unsubscribe: (callback: (state: any) => void) => void
|
|
33
|
+
mutate: GlobalStateStoreMutateMethod
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Offline Interface types
|
|
37
|
+
|
|
38
|
+
interface StartRecording {
|
|
39
|
+
(params: {
|
|
40
|
+
sectionId: string
|
|
41
|
+
recordingTimeoutDelay: number
|
|
42
|
+
onStarted: () => void
|
|
43
|
+
onCompleted: () => void
|
|
44
|
+
onError: (err: Error) => void
|
|
45
|
+
}): Promise<undefined>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface IndexedDBCachedSection {
|
|
49
|
+
sectionId: string
|
|
50
|
+
lastUpdated: Date
|
|
51
|
+
// this may change in the future:
|
|
52
|
+
requests: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface OfflineInterface {
|
|
56
|
+
readonly pwaEnabled: boolean
|
|
57
|
+
readonly latestIsConnected: boolean | null
|
|
58
|
+
subscribeToDhis2ConnectionStatus: ({
|
|
59
|
+
onUpdate,
|
|
60
|
+
}: {
|
|
61
|
+
onUpdate: ({ isConnected }: { isConnected: boolean }) => void
|
|
62
|
+
}) => () => void
|
|
63
|
+
startRecording: StartRecording
|
|
64
|
+
getCachedSections: () => Promise<IndexedDBCachedSection[]>
|
|
65
|
+
removeSection: (id: string) => Promise<boolean>
|
|
66
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen } from '@testing-library/react'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { RenderCounter, resetRenderCounts } from '../render-counter'
|
|
4
|
+
|
|
5
|
+
const renderCounts = {}
|
|
6
|
+
|
|
7
|
+
export const Rerenderer = (): JSX.Element => {
|
|
8
|
+
const [, setState] = React.useState(true)
|
|
9
|
+
const toggleState = () => setState((prevState) => !prevState)
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<button onClick={toggleState} role="button" />
|
|
14
|
+
<RenderCounter id={'rc1'} countsObj={renderCounts} />
|
|
15
|
+
</>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
resetRenderCounts(renderCounts)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('increments the counter when rerendered', () => {
|
|
24
|
+
render(<Rerenderer />)
|
|
25
|
+
|
|
26
|
+
const { getByTestId, getByRole } = screen
|
|
27
|
+
expect(getByTestId('rc1')).toHaveTextContent('1')
|
|
28
|
+
|
|
29
|
+
act(() => {
|
|
30
|
+
fireEvent.click(getByRole('button'))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
expect(getByTestId('rc1')).toHaveTextContent('2')
|
|
34
|
+
|
|
35
|
+
act(() => {
|
|
36
|
+
fireEvent.click(getByRole('button'))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
expect(getByTestId('rc1')).toHaveTextContent('3')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('resets the render counter successfully', () => {
|
|
43
|
+
render(<Rerenderer />)
|
|
44
|
+
expect(screen.getByTestId('rc1')).toHaveTextContent('1')
|
|
45
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/* eslint-disable react-hooks/immutability */
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
interface RenderCounts {
|
|
5
|
+
[index: string]: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const RenderCounter = ({
|
|
9
|
+
id,
|
|
10
|
+
countsObj,
|
|
11
|
+
}: {
|
|
12
|
+
id: string
|
|
13
|
+
countsObj: RenderCounts
|
|
14
|
+
}): JSX.Element => {
|
|
15
|
+
if (!(id in countsObj)) {
|
|
16
|
+
countsObj[id] = 0
|
|
17
|
+
}
|
|
18
|
+
return <div data-testid={id}>{++countsObj[id]}</div>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const resetRenderCounts = (renderCounts: RenderCounts): void =>
|
|
22
|
+
Object.keys(renderCounts).forEach((key) => (renderCounts[key] = 0))
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export const successfulRecordingMock = jest
|
|
2
|
+
.fn()
|
|
3
|
+
.mockImplementation(async ({ onStarted, onCompleted } = {}) => {
|
|
4
|
+
// in 100ms, call 'onStarted' callback (allows 'pending' state)
|
|
5
|
+
if (onStarted) {
|
|
6
|
+
setTimeout(onStarted, 100)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// in 200ms, call 'onCompleted' callback
|
|
10
|
+
if (onCompleted) {
|
|
11
|
+
setTimeout(onCompleted, 200)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// resolve
|
|
15
|
+
return Promise.resolve()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
export const errorRecordingMock = jest
|
|
19
|
+
.fn()
|
|
20
|
+
.mockImplementation(({ onStarted, onError } = {}) => {
|
|
21
|
+
// in 100ms, call 'onStarted' callback (allows 'pending' state)
|
|
22
|
+
if (onStarted) {
|
|
23
|
+
setTimeout(onStarted, 100)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// in 200ms, call 'onError'
|
|
27
|
+
setTimeout(() => onError(new Error('test err')), 200)
|
|
28
|
+
|
|
29
|
+
// resolve to signal successful initiation
|
|
30
|
+
return Promise.resolve()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
export const failedMessageRecordingMock = jest
|
|
34
|
+
.fn()
|
|
35
|
+
.mockRejectedValue(new Error('Failed message'))
|
|
36
|
+
|
|
37
|
+
export const mockOfflineInterface = {
|
|
38
|
+
pwaEnabled: true,
|
|
39
|
+
latestIsConnected: true,
|
|
40
|
+
startRecording: successfulRecordingMock,
|
|
41
|
+
getCachedSections: jest.fn().mockResolvedValue([]),
|
|
42
|
+
removeSection: jest.fn().mockResolvedValue(true),
|
|
43
|
+
// returns an unsubscribe function
|
|
44
|
+
subscribeToDhis2ConnectionStatus: jest
|
|
45
|
+
.fn()
|
|
46
|
+
.mockReturnValue(() => undefined),
|
|
47
|
+
}
|