@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,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
+ }
@@ -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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src"],
4
+ "exclude": [
5
+ "src/setupRTL.ts",
6
+ "src/__tests__",
7
+ "**/*.test.ts",
8
+ "**/*.test.tsx"
9
+ ]
10
+ }