@dhis2/app-service-offline 2.10.0 → 2.12.0

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 (41) hide show
  1. package/build/cjs/__tests__/integration.test.js +337 -0
  2. package/build/cjs/index.js +39 -1
  3. package/build/cjs/lib/__tests__/clear-sensitive-caches.test.js +131 -0
  4. package/build/cjs/lib/__tests__/offline-provider.test.js +127 -0
  5. package/build/cjs/lib/__tests__/use-cacheable-section.test.js +227 -0
  6. package/build/cjs/lib/cacheable-section-state.js +218 -0
  7. package/build/cjs/lib/cacheable-section.js +156 -0
  8. package/build/cjs/lib/clear-sensitive-caches.js +87 -0
  9. package/build/cjs/lib/global-state-service.js +95 -0
  10. package/build/cjs/lib/offline-interface.js +86 -0
  11. package/build/cjs/lib/offline-provider.js +53 -0
  12. package/build/cjs/types.js +0 -1
  13. package/build/cjs/utils/__tests__/render-counter.test.js +55 -0
  14. package/build/cjs/utils/render-counter.js +26 -0
  15. package/build/cjs/utils/test-mocks.js +40 -0
  16. package/build/es/__tests__/integration.test.js +327 -0
  17. package/build/es/index.js +5 -1
  18. package/build/es/lib/__tests__/clear-sensitive-caches.test.js +123 -0
  19. package/build/es/lib/__tests__/offline-provider.test.js +117 -0
  20. package/build/es/lib/__tests__/use-cacheable-section.test.js +218 -0
  21. package/build/es/lib/cacheable-section-state.js +199 -0
  22. package/build/es/lib/cacheable-section.js +137 -0
  23. package/build/es/lib/clear-sensitive-caches.js +78 -0
  24. package/build/es/lib/global-state-service.js +70 -0
  25. package/build/es/lib/offline-interface.js +65 -0
  26. package/build/es/lib/offline-provider.js +40 -0
  27. package/build/es/types.js +0 -1
  28. package/build/es/utils/__tests__/render-counter.test.js +40 -0
  29. package/build/es/utils/render-counter.js +11 -0
  30. package/build/es/utils/test-mocks.js +30 -0
  31. package/build/types/index.d.ts +4 -0
  32. package/build/types/lib/cacheable-section-state.d.ts +66 -0
  33. package/build/types/lib/cacheable-section.d.ts +52 -0
  34. package/build/types/lib/clear-sensitive-caches.d.ts +16 -0
  35. package/build/types/lib/global-state-service.d.ts +16 -0
  36. package/build/types/lib/offline-interface.d.ts +26 -0
  37. package/build/types/lib/offline-provider.d.ts +19 -0
  38. package/build/types/types.d.ts +50 -0
  39. package/build/types/utils/render-counter.d.ts +10 -0
  40. package/build/types/utils/test-mocks.d.ts +11 -0
  41. package/package.json +2 -2
@@ -0,0 +1,65 @@
1
+ import { useAlert } from '@dhis2/app-service-alerts';
2
+ import PropTypes from 'prop-types';
3
+ import React, { createContext, useContext } from 'react';
4
+ // This is to prevent 'offlineInterface could be null' type-checking errors
5
+ const noopOfflineInterface = {
6
+ pwaEnabled: false,
7
+ init: () => () => null,
8
+ startRecording: async () => undefined,
9
+ getCachedSections: async () => [],
10
+ removeSection: async () => false
11
+ };
12
+ const OfflineInterfaceContext = /*#__PURE__*/createContext(noopOfflineInterface);
13
+
14
+ /**
15
+ * Receives an OfflineInterface instance as a prop (presumably from the app
16
+ * adapter) and provides it as context for other offline tools.
17
+ *
18
+ * On mount, it initializes the offline interface, which (among other things)
19
+ * checks for service worker updates and, if updates are ready, prompts the
20
+ * user with an alert to skip waiting and reload the page to use new content.
21
+ */
22
+ export function OfflineInterfaceProvider({
23
+ offlineInterface,
24
+ children
25
+ }) {
26
+ const {
27
+ show
28
+ } = useAlert(({
29
+ message
30
+ }) => message, ({
31
+ action,
32
+ onConfirm
33
+ }) => ({
34
+ actions: [{
35
+ label: action,
36
+ onClick: onConfirm
37
+ }],
38
+ permanent: true
39
+ }));
40
+ React.useEffect(() => {
41
+ // Init returns a tear-down function
42
+ return offlineInterface.init({
43
+ promptUpdate: show
44
+ });
45
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
46
+
47
+ return /*#__PURE__*/React.createElement(OfflineInterfaceContext.Provider, {
48
+ value: offlineInterface
49
+ }, children);
50
+ }
51
+ OfflineInterfaceProvider.propTypes = {
52
+ children: PropTypes.node,
53
+ offlineInterface: PropTypes.shape({
54
+ init: PropTypes.func
55
+ })
56
+ };
57
+ export function useOfflineInterface() {
58
+ const offlineInterface = useContext(OfflineInterfaceContext);
59
+
60
+ if (offlineInterface === undefined) {
61
+ throw new Error('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.');
62
+ }
63
+
64
+ return offlineInterface;
65
+ }
@@ -0,0 +1,40 @@
1
+ import PropTypes from 'prop-types';
2
+ import React from 'react';
3
+ import { CacheableSectionProvider } from './cacheable-section-state';
4
+ import { OfflineInterfaceProvider } from './offline-interface';
5
+
6
+ /** A context provider for all the relevant offline contexts */
7
+ export function OfflineProvider({
8
+ offlineInterface,
9
+ children
10
+ }) {
11
+ // If an offline interface is not provided, or if one is provided and PWA
12
+ // is not enabled, skip adding context providers
13
+ if (!offlineInterface) {
14
+ return /*#__PURE__*/React.createElement(React.Fragment, null, children);
15
+ } // If PWA is not enabled, just init interface to make sure new SW gets
16
+ // activated with code that unregisters SW. Not technically necessary if a
17
+ // killswitch SW takes over, but the killswitch may not always be in use.
18
+ // Then, skip adding any context
19
+
20
+
21
+ if (!offlineInterface.pwaEnabled) {
22
+ offlineInterface.init({
23
+ promptUpdate: ({
24
+ onConfirm
25
+ }) => onConfirm()
26
+ });
27
+ return /*#__PURE__*/React.createElement(React.Fragment, null, children);
28
+ }
29
+
30
+ return /*#__PURE__*/React.createElement(OfflineInterfaceProvider, {
31
+ offlineInterface: offlineInterface
32
+ }, /*#__PURE__*/React.createElement(CacheableSectionProvider, null, children));
33
+ }
34
+ OfflineProvider.propTypes = {
35
+ children: PropTypes.node,
36
+ offlineInterface: PropTypes.shape({
37
+ init: PropTypes.func,
38
+ pwaEnabled: PropTypes.bool
39
+ })
40
+ };
package/build/es/types.js CHANGED
@@ -1 +0,0 @@
1
- // todo
@@ -0,0 +1,40 @@
1
+ import { act, fireEvent, render, screen } from '@testing-library/react';
2
+ import React from 'react';
3
+ import { RenderCounter, resetRenderCounts } from '../render-counter';
4
+ const renderCounts = {};
5
+ export const Rerenderer = () => {
6
+ const [, setState] = React.useState(true);
7
+
8
+ const toggleState = () => setState(prevState => !prevState);
9
+
10
+ return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("button", {
11
+ onClick: toggleState,
12
+ role: "button"
13
+ }), /*#__PURE__*/React.createElement(RenderCounter, {
14
+ id: 'rc1',
15
+ countsObj: renderCounts
16
+ }));
17
+ };
18
+ afterEach(() => {
19
+ resetRenderCounts(renderCounts);
20
+ });
21
+ it('increments the counter when rerendered', () => {
22
+ render( /*#__PURE__*/React.createElement(Rerenderer, null));
23
+ const {
24
+ getByTestId,
25
+ getByRole
26
+ } = screen;
27
+ expect(getByTestId('rc1')).toHaveTextContent('1');
28
+ act(() => {
29
+ fireEvent.click(getByRole('button'));
30
+ });
31
+ expect(getByTestId('rc1')).toHaveTextContent('2');
32
+ act(() => {
33
+ fireEvent.click(getByRole('button'));
34
+ });
35
+ expect(getByTestId('rc1')).toHaveTextContent('3');
36
+ });
37
+ it('resets the render counter successfully', () => {
38
+ render( /*#__PURE__*/React.createElement(Rerenderer, null));
39
+ expect(screen.getByTestId('rc1')).toHaveTextContent('1');
40
+ });
@@ -0,0 +1,11 @@
1
+ import React from 'react';
2
+ export const RenderCounter = ({
3
+ id,
4
+ countsObj
5
+ }) => {
6
+ if (!(id in countsObj)) countsObj[id] = 0;
7
+ return /*#__PURE__*/React.createElement("div", {
8
+ "data-testid": id
9
+ }, ++countsObj[id]);
10
+ };
11
+ export const resetRenderCounts = renderCounts => Object.keys(renderCounts).forEach(key => renderCounts[key] = 0);
@@ -0,0 +1,30 @@
1
+ export const successfulRecordingMock = jest.fn().mockImplementation(async ({
2
+ onStarted,
3
+ onCompleted
4
+ } = {}) => {
5
+ // in 100ms, call 'onStarted' callback (allows 'pending' state)
6
+ if (onStarted) setTimeout(onStarted, 100); // in 200ms, call 'onCompleted' callback
7
+
8
+ if (onCompleted) setTimeout(onCompleted, 200); // resolve
9
+
10
+ return Promise.resolve();
11
+ });
12
+ export const errorRecordingMock = jest.fn().mockImplementation(({
13
+ onStarted,
14
+ onError
15
+ } = {}) => {
16
+ // in 100ms, call 'onStarted' callback (allows 'pending' state)
17
+ if (onStarted) setTimeout(onStarted, 100); // in 200ms, call 'onError'
18
+
19
+ setTimeout(() => onError(new Error('test err')), 200); // resolve to signal successful initiation
20
+
21
+ return Promise.resolve();
22
+ });
23
+ export const failedMessageRecordingMock = jest.fn().mockRejectedValue(new Error('Failed message'));
24
+ export const mockOfflineInterface = {
25
+ pwaEnabled: true,
26
+ init: jest.fn(),
27
+ startRecording: successfulRecordingMock,
28
+ getCachedSections: jest.fn().mockResolvedValue([]),
29
+ removeSection: jest.fn().mockResolvedValue(true)
30
+ };
@@ -1 +1,5 @@
1
+ export { OfflineProvider } from './lib/offline-provider';
2
+ export { CacheableSection, useCacheableSection } from './lib/cacheable-section';
3
+ export { useCachedSections } from './lib/cacheable-section-state';
1
4
  export { useOnlineStatus } from './lib/online-status';
5
+ export { clearSensitiveCaches } from './lib/clear-sensitive-caches';
@@ -0,0 +1,66 @@
1
+ import PropTypes from 'prop-types';
2
+ import React from 'react';
3
+ import { GlobalStateStore, RecordingState } from '../types';
4
+ interface CachedSectionsById {
5
+ [index: string]: {
6
+ lastUpdated: Date;
7
+ };
8
+ }
9
+ /**
10
+ * Create a store for Cacheable Section state.
11
+ * Expected to be used in app adapter
12
+ */
13
+ export declare function createCacheableSectionStore(): GlobalStateStore;
14
+ /**
15
+ * Provides context for a global state context which will track cached
16
+ * sections' status and cacheable sections' recording states, which will
17
+ * determine how that component will render. The provider will be a part of
18
+ * the OfflineProvider.
19
+ */
20
+ export declare function CacheableSectionProvider({ children, }: {
21
+ children: React.ReactNode;
22
+ }): JSX.Element;
23
+ export declare namespace CacheableSectionProvider {
24
+ var propTypes: {
25
+ children: PropTypes.Requireable<PropTypes.ReactNodeLike>;
26
+ };
27
+ }
28
+ interface RecordingStateControls {
29
+ recordingState: RecordingState;
30
+ setRecordingState: (newState: RecordingState) => void;
31
+ removeRecordingState: () => void;
32
+ }
33
+ /**
34
+ * Uses an optimized global state to manage 'recording state' values without
35
+ * unnecessarily rerendering all consuming components
36
+ *
37
+ * @param {String} id - ID of the cacheable section to track
38
+ * @returns {Object} { recordingState: String, setRecordingState: Function, removeRecordingState: Function}
39
+ */
40
+ export declare function useRecordingState(id: string): RecordingStateControls;
41
+ interface CachedSectionsControls {
42
+ cachedSections: CachedSectionsById;
43
+ removeById: (id: string) => Promise<boolean>;
44
+ syncCachedSections: () => Promise<void>;
45
+ }
46
+ /**
47
+ * Uses global state to manage an object of cached sections' statuses
48
+ *
49
+ * @returns {Object} { cachedSections: Object, removeSection: Function }
50
+ */
51
+ export declare function useCachedSections(): CachedSectionsControls;
52
+ interface CachedSectionControls {
53
+ lastUpdated: Date;
54
+ isCached: boolean;
55
+ remove: () => Promise<boolean>;
56
+ syncCachedSections: () => Promise<void>;
57
+ }
58
+ /**
59
+ * Uses global state to manage the cached status of just one section, which
60
+ * prevents unnecessary rerenders of consuming components
61
+ *
62
+ * @param {String} id
63
+ * @returns {Object} { lastUpdated: Date, remove: Function }
64
+ */
65
+ export declare function useCachedSection(id: string): CachedSectionControls;
66
+ export {};
@@ -0,0 +1,52 @@
1
+ import PropTypes from 'prop-types';
2
+ import React from 'react';
3
+ import { RecordingState } from '../types';
4
+ interface CacheableSectionStartRecordingOptions {
5
+ recordingTimeoutDelay?: number;
6
+ onStarted?: () => void;
7
+ onCompleted?: () => void;
8
+ onError?: (err: Error) => void;
9
+ }
10
+ export declare type CacheableSectionStartRecording = (options?: CacheableSectionStartRecordingOptions) => Promise<any>;
11
+ interface CacheableSectionControls {
12
+ recordingState: RecordingState;
13
+ startRecording: CacheableSectionStartRecording;
14
+ lastUpdated: Date | undefined;
15
+ isCached: boolean;
16
+ remove: () => Promise<boolean>;
17
+ }
18
+ /**
19
+ * Returns the main controls for a cacheable section and manages recording
20
+ * state, which affects the render state of the CacheableSection component.
21
+ * Also returns the cached status of the section, which come straight from
22
+ * the `useCachedSection` hook.
23
+ *
24
+ * @param {String} id
25
+ * @returns {Object}
26
+ */
27
+ export declare function useCacheableSection(id: string): CacheableSectionControls;
28
+ interface CacheableSectionProps {
29
+ id: string;
30
+ loadingMask: JSX.Element;
31
+ children: React.ReactNode;
32
+ }
33
+ /**
34
+ * Used to wrap the relevant component to be recorded and saved offline.
35
+ * Depending on the recording state of the section, this wrapper will
36
+ * render its children, not render its children while recording is pending,
37
+ * or RErerender the chilren to force data fetching to record by the service
38
+ * worker.
39
+ *
40
+ * During recording, a loading mask provided by props is also rendered that is
41
+ * intended to prevent other interaction with the app that might interfere
42
+ * with the recording process.
43
+ */
44
+ export declare function CacheableSection({ id, loadingMask, children, }: CacheableSectionProps): JSX.Element;
45
+ export declare namespace CacheableSection {
46
+ var propTypes: {
47
+ id: PropTypes.Validator<string>;
48
+ children: PropTypes.Requireable<PropTypes.ReactNodeLike>;
49
+ loadingMask: PropTypes.Requireable<PropTypes.ReactNodeLike>;
50
+ };
51
+ }
52
+ export {};
@@ -0,0 +1,16 @@
1
+ export declare const SECTIONS_DB = "sections-db";
2
+ export declare const SECTIONS_STORE = "sections-store";
3
+ declare global {
4
+ interface IDBFactory {
5
+ databases(): Promise<[{
6
+ name: string;
7
+ version: number;
8
+ }]>;
9
+ }
10
+ }
11
+ /**
12
+ * Used to clear caches and 'sections-db' IndexedDB when a user logs out or a
13
+ * different user logs in to prevent someone from accessing a different user's
14
+ * caches. Should be able to be used in a non-PWA app.
15
+ */
16
+ export declare function clearSensitiveCaches(dbName?: string): Promise<any>;
@@ -0,0 +1,16 @@
1
+ import PropTypes from 'prop-types';
2
+ import React from 'react';
3
+ import { GlobalStateStore, GlobalStateStoreMutateMethod, GlobalStateMutation, GlobalStateStoreMutationCreator } from '../types';
4
+ export declare const createStore: (initialState?: {}) => GlobalStateStore;
5
+ export declare const GlobalStateProvider: {
6
+ ({ store, children, }: {
7
+ store: GlobalStateStore;
8
+ children: React.ReactNode;
9
+ }): JSX.Element;
10
+ propTypes: {
11
+ children: PropTypes.Requireable<PropTypes.ReactNodeLike>;
12
+ store: PropTypes.Requireable<PropTypes.InferProps<{}>>;
13
+ };
14
+ };
15
+ export declare const useGlobalState: (selector?: (state: any) => any) => [any, GlobalStateStoreMutateMethod];
16
+ export declare function useGlobalStateMutation<Type>(mutationCreator: GlobalStateStoreMutationCreator<Type>): GlobalStateMutation<Type>;
@@ -0,0 +1,26 @@
1
+ import PropTypes from 'prop-types';
2
+ import React from 'react';
3
+ import { OfflineInterface } from '../types';
4
+ interface OfflineInterfaceProviderInput {
5
+ offlineInterface: OfflineInterface;
6
+ children: React.ReactNode;
7
+ }
8
+ /**
9
+ * Receives an OfflineInterface instance as a prop (presumably from the app
10
+ * adapter) and provides it as context for other offline tools.
11
+ *
12
+ * On mount, it initializes the offline interface, which (among other things)
13
+ * checks for service worker updates and, if updates are ready, prompts the
14
+ * user with an alert to skip waiting and reload the page to use new content.
15
+ */
16
+ export declare function OfflineInterfaceProvider({ offlineInterface, children, }: OfflineInterfaceProviderInput): JSX.Element;
17
+ export declare namespace OfflineInterfaceProvider {
18
+ var propTypes: {
19
+ children: PropTypes.Requireable<PropTypes.ReactNodeLike>;
20
+ offlineInterface: PropTypes.Requireable<PropTypes.InferProps<{
21
+ init: PropTypes.Requireable<(...args: any[]) => any>;
22
+ }>>;
23
+ };
24
+ }
25
+ export declare function useOfflineInterface(): OfflineInterface;
26
+ export {};
@@ -0,0 +1,19 @@
1
+ import PropTypes from 'prop-types';
2
+ import React from 'react';
3
+ import { OfflineInterface } from '../types';
4
+ interface OfflineProviderInput {
5
+ offlineInterface?: OfflineInterface;
6
+ children?: React.ReactNode;
7
+ }
8
+ /** A context provider for all the relevant offline contexts */
9
+ export declare function OfflineProvider({ offlineInterface, children, }: OfflineProviderInput): JSX.Element;
10
+ export declare namespace OfflineProvider {
11
+ var propTypes: {
12
+ children: PropTypes.Requireable<PropTypes.ReactNodeLike>;
13
+ offlineInterface: PropTypes.Requireable<PropTypes.InferProps<{
14
+ init: PropTypes.Requireable<(...args: any[]) => any>;
15
+ pwaEnabled: PropTypes.Requireable<boolean>;
16
+ }>>;
17
+ };
18
+ }
19
+ export {};
@@ -0,0 +1,50 @@
1
+ export declare type RecordingState = 'default' | 'pending' | 'error' | 'recording';
2
+ export interface GlobalStateStoreMutation {
3
+ (state: any): any;
4
+ }
5
+ export interface GlobalStateStoreMutationCreator<Type> {
6
+ (...args: Type[]): GlobalStateStoreMutation;
7
+ }
8
+ export interface GlobalStateMutation<Type> {
9
+ (...args: Type[]): void;
10
+ }
11
+ export interface GlobalStateStoreMutateMethod {
12
+ (mutation: GlobalStateStoreMutation): void;
13
+ }
14
+ export interface GlobalStateStore {
15
+ getState: () => any;
16
+ subscribe: (callback: (state: any) => void) => void;
17
+ unsubscribe: (callback: (state: any) => void) => void;
18
+ mutate: GlobalStateStoreMutateMethod;
19
+ }
20
+ interface PromptUpdate {
21
+ (params: {
22
+ message: string;
23
+ action: string;
24
+ onConfirm: () => void;
25
+ }): void;
26
+ }
27
+ interface StartRecording {
28
+ (params: {
29
+ sectionId: string;
30
+ recordingTimeoutDelay: number;
31
+ onStarted: () => void;
32
+ onCompleted: () => void;
33
+ onError: (err: Error) => void;
34
+ }): Promise<undefined>;
35
+ }
36
+ export interface IndexedDBCachedSection {
37
+ sectionId: string;
38
+ lastUpdated: Date;
39
+ requests: number;
40
+ }
41
+ export interface OfflineInterface {
42
+ readonly pwaEnabled: boolean;
43
+ init: (params: {
44
+ promptUpdate: PromptUpdate;
45
+ }) => () => void;
46
+ startRecording: StartRecording;
47
+ getCachedSections: () => Promise<IndexedDBCachedSection[]>;
48
+ removeSection: (id: string) => Promise<boolean>;
49
+ }
50
+ export {};