@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,156 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.useCacheableSection = useCacheableSection;
7
+ exports.CacheableSection = CacheableSection;
8
+
9
+ var _propTypes = _interopRequireDefault(require("prop-types"));
10
+
11
+ var _react = _interopRequireWildcard(require("react"));
12
+
13
+ var _cacheableSectionState = require("./cacheable-section-state");
14
+
15
+ var _offlineInterface = require("./offline-interface");
16
+
17
+ function _getRequireWildcardCache() { if (typeof WeakMap !== "function") return null; var cache = new WeakMap(); _getRequireWildcardCache = function () { return cache; }; return cache; }
18
+
19
+ function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
20
+
21
+ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
22
+
23
+ const recordingStates = {
24
+ default: 'default',
25
+ pending: 'pending',
26
+ recording: 'recording',
27
+ error: 'error'
28
+ };
29
+
30
+ /**
31
+ * Returns the main controls for a cacheable section and manages recording
32
+ * state, which affects the render state of the CacheableSection component.
33
+ * Also returns the cached status of the section, which come straight from
34
+ * the `useCachedSection` hook.
35
+ *
36
+ * @param {String} id
37
+ * @returns {Object}
38
+ */
39
+ function useCacheableSection(id) {
40
+ const offlineInterface = (0, _offlineInterface.useOfflineInterface)();
41
+ const {
42
+ isCached,
43
+ lastUpdated,
44
+ remove,
45
+ syncCachedSections
46
+ } = (0, _cacheableSectionState.useCachedSection)(id);
47
+ const {
48
+ recordingState,
49
+ setRecordingState,
50
+ removeRecordingState
51
+ } = (0, _cacheableSectionState.useRecordingState)(id);
52
+ (0, _react.useEffect)(() => {
53
+ // On mount, add recording state for this ID to context if needed
54
+ if (!recordingState) {
55
+ setRecordingState(recordingStates.default);
56
+ } // On unnmount, remove recording state if not recording
57
+
58
+
59
+ return () => {
60
+ if (recordingState && recordingState !== recordingStates.recording && recordingState !== recordingStates.pending) {
61
+ removeRecordingState();
62
+ }
63
+ };
64
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
65
+
66
+ function startRecording({
67
+ recordingTimeoutDelay = 1000,
68
+ onStarted,
69
+ onCompleted,
70
+ onError
71
+ } = {}) {
72
+ // This promise resolving means that the message to the service worker
73
+ // to start recording was successful. Waiting for resolution prevents
74
+ // unnecessarily rerendering the whole component in case of an error
75
+ return offlineInterface.startRecording({
76
+ sectionId: id,
77
+ recordingTimeoutDelay,
78
+ onStarted: () => {
79
+ onRecordingStarted();
80
+ onStarted && onStarted();
81
+ },
82
+ onCompleted: () => {
83
+ onRecordingCompleted();
84
+ onCompleted && onCompleted();
85
+ },
86
+ onError: error => {
87
+ onRecordingError(error);
88
+ onError && onError(error);
89
+ }
90
+ }).then(() => setRecordingState(recordingStates.pending));
91
+ }
92
+
93
+ function onRecordingStarted() {
94
+ setRecordingState(recordingStates.recording);
95
+ }
96
+
97
+ function onRecordingCompleted() {
98
+ setRecordingState(recordingStates.default);
99
+ syncCachedSections();
100
+ }
101
+
102
+ function onRecordingError(error) {
103
+ console.error('Error during recording:', error);
104
+ setRecordingState(recordingStates.error);
105
+ } // isCached, lastUpdated, remove: _could_ be accessed by useCachedSection,
106
+ // but provided through this hook for convenience
107
+
108
+
109
+ return {
110
+ recordingState,
111
+ startRecording,
112
+ lastUpdated,
113
+ isCached,
114
+ remove
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Used to wrap the relevant component to be recorded and saved offline.
120
+ * Depending on the recording state of the section, this wrapper will
121
+ * render its children, not render its children while recording is pending,
122
+ * or RErerender the chilren to force data fetching to record by the service
123
+ * worker.
124
+ *
125
+ * During recording, a loading mask provided by props is also rendered that is
126
+ * intended to prevent other interaction with the app that might interfere
127
+ * with the recording process.
128
+ */
129
+ function CacheableSection({
130
+ id,
131
+ loadingMask,
132
+ children
133
+ }) {
134
+ // Accesses recording state that useCacheableSection controls
135
+ const {
136
+ recordingState
137
+ } = (0, _cacheableSectionState.useRecordingState)(id); // The following causes the component to reload in the event of a recording
138
+ // error; the state will be cleared next time recording moves to pending.
139
+ // It fixes a component getting stuck while rendered without data after
140
+ // failing a recording while offline.
141
+ // Errors can be handled in the `onError` callback to `startRecording`.
142
+
143
+ if (recordingState === recordingStates.error) {
144
+ return /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, children);
145
+ } // Handling rendering with the following conditions prevents an unncessary
146
+ // rerender after successful recording
147
+
148
+
149
+ return /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, recordingState === recordingStates.recording && loadingMask, recordingState !== recordingStates.pending && children);
150
+ }
151
+
152
+ CacheableSection.propTypes = {
153
+ id: _propTypes.default.string.isRequired,
154
+ children: _propTypes.default.node,
155
+ loadingMask: _propTypes.default.node
156
+ };
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.clearSensitiveCaches = clearSensitiveCaches;
7
+ exports.SECTIONS_STORE = exports.SECTIONS_DB = void 0;
8
+ // IndexedDB names; should be the same as in @dhis2/pwa
9
+ const SECTIONS_DB = 'sections-db';
10
+ exports.SECTIONS_DB = SECTIONS_DB;
11
+ const SECTIONS_STORE = 'sections-store'; // Non-sensitive caches that can be kept:
12
+
13
+ exports.SECTIONS_STORE = SECTIONS_STORE;
14
+ const KEEPABLE_CACHES = [/^workbox-precache/, // precached static assets
15
+ /^other-assets/ // static assets cached at runtime - shouldn't be sensitive
16
+ ];
17
+
18
+ /*
19
+ * Clears the 'sections-db' IndexedDB if it exists. Designed to avoid opening
20
+ * a new DB if it doesn't exist yet. Firefox can't check if 'sections-db'
21
+ * exists, in which circumstance the IndexedDB is unaffected. It's inelegant
22
+ * but acceptable because the IndexedDB has no sensitive data (only metadata
23
+ * of recorded sections), and the OfflineInterface handles discrepancies
24
+ * between CacheStorage and IndexedDB.
25
+ */
26
+ const clearDB = async dbName => {
27
+ if (!('databases' in indexedDB)) {
28
+ // FF does not have indexedDB.databases. For that, just clear caches,
29
+ // and offline interface will handle discrepancies in PWA apps.
30
+ return;
31
+ }
32
+
33
+ const dbs = await window.indexedDB.databases();
34
+
35
+ if (!dbs.some(({
36
+ name
37
+ }) => name === dbName)) {
38
+ // Sections-db is not created; nothing to do here
39
+ return;
40
+ }
41
+
42
+ return new Promise((resolve, reject) => {
43
+ // IndexedDB fun:
44
+ const openDBRequest = indexedDB.open(dbName);
45
+
46
+ openDBRequest.onsuccess = e => {
47
+ const db = e.target.result;
48
+ const tx = db.transaction(SECTIONS_STORE, 'readwrite'); // When the transaction completes is when the operation is done:
49
+
50
+ tx.oncomplete = () => resolve();
51
+
52
+ tx.onerror = e => reject(e.target.error);
53
+
54
+ const os = tx.objectStore(SECTIONS_STORE);
55
+ const clearReq = os.clear();
56
+
57
+ clearReq.onerror = e => reject(e.target.error);
58
+ };
59
+
60
+ openDBRequest.onerror = e => {
61
+ reject(e.target.error);
62
+ };
63
+ });
64
+ };
65
+ /**
66
+ * Used to clear caches and 'sections-db' IndexedDB when a user logs out or a
67
+ * different user logs in to prevent someone from accessing a different user's
68
+ * caches. Should be able to be used in a non-PWA app.
69
+ */
70
+
71
+
72
+ async function clearSensitiveCaches(dbName = SECTIONS_DB) {
73
+ console.debug('Clearing sensitive caches');
74
+ const cacheKeys = await caches.keys();
75
+ return Promise.all([clearDB(dbName), // remove caches if not in keepable list
76
+ ...cacheKeys.map(key => {
77
+ if (!KEEPABLE_CACHES.some(pattern => pattern.test(key))) {
78
+ // .then() satisfies typescript
79
+ return caches.delete(key).then(() => undefined);
80
+ }
81
+ })]).then(responses => {
82
+ // Return true if any caches have been cleared
83
+ // (caches.delete() returns true if a cache is deleted successfully)
84
+ // PWA apps can reload to restore their app shell cache
85
+ return responses.some(response => response);
86
+ });
87
+ }
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.useGlobalStateMutation = useGlobalStateMutation;
7
+ exports.useGlobalState = exports.GlobalStateProvider = exports.createStore = void 0;
8
+
9
+ var _isEqual = _interopRequireDefault(require("lodash/isEqual"));
10
+
11
+ var _propTypes = _interopRequireDefault(require("prop-types"));
12
+
13
+ var _react = _interopRequireWildcard(require("react"));
14
+
15
+ function _getRequireWildcardCache() { if (typeof WeakMap !== "function") return null; var cache = new WeakMap(); _getRequireWildcardCache = function () { return cache; }; return cache; }
16
+
17
+ function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
18
+
19
+ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
20
+
21
+ // This file creates a redux-like state management service using React context
22
+ // that minimizes unnecessary rerenders that consume the context.
23
+ // See more at https://github.com/amcgee/state-service-poc
24
+ const identity = state => state;
25
+
26
+ const createStore = (initialState = {}) => {
27
+ const subscriptions = new Set();
28
+ let state = initialState;
29
+ return {
30
+ getState: () => state,
31
+ subscribe: callback => {
32
+ subscriptions.add(callback);
33
+ },
34
+ unsubscribe: callback => {
35
+ subscriptions.delete(callback);
36
+ },
37
+ mutate: mutation => {
38
+ state = mutation(state);
39
+
40
+ for (const callback of subscriptions) {
41
+ callback(state);
42
+ }
43
+ }
44
+ };
45
+ };
46
+
47
+ exports.createStore = createStore;
48
+
49
+ const GlobalStateContext = /*#__PURE__*/_react.default.createContext(createStore());
50
+
51
+ const useGlobalStateStore = () => (0, _react.useContext)(GlobalStateContext);
52
+
53
+ const GlobalStateProvider = ({
54
+ store,
55
+ children
56
+ }) => /*#__PURE__*/_react.default.createElement(GlobalStateContext.Provider, {
57
+ value: store
58
+ }, children);
59
+
60
+ exports.GlobalStateProvider = GlobalStateProvider;
61
+ GlobalStateProvider.propTypes = {
62
+ children: _propTypes.default.node,
63
+ store: _propTypes.default.shape({})
64
+ };
65
+
66
+ const useGlobalState = (selector = identity) => {
67
+ const store = useGlobalStateStore();
68
+ const [selectedState, setSelectedState] = (0, _react.useState)(selector(store.getState()));
69
+ (0, _react.useEffect)(() => {
70
+ // NEW: deep equality check before updating
71
+ const callback = state => {
72
+ const newSelectedState = selector(state); // Second condition handles case where a selected object gets
73
+ // deleted, but state does not update
74
+
75
+ if (!(0, _isEqual.default)(selectedState, newSelectedState) || selectedState === undefined) setSelectedState(newSelectedState);
76
+ };
77
+
78
+ store.subscribe(callback); // Make sure to update state when selector changes
79
+
80
+ callback(store.getState());
81
+ return () => store.unsubscribe(callback);
82
+ }, [store, selector]);
83
+ /* eslint-disable-line react-hooks/exhaustive-deps */
84
+
85
+ return [selectedState, store.mutate];
86
+ };
87
+
88
+ exports.useGlobalState = useGlobalState;
89
+
90
+ function useGlobalStateMutation(mutationCreator) {
91
+ const store = useGlobalStateStore();
92
+ return (0, _react.useCallback)((...args) => {
93
+ store.mutate(mutationCreator(...args));
94
+ }, [mutationCreator, store]);
95
+ }
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.OfflineInterfaceProvider = OfflineInterfaceProvider;
7
+ exports.useOfflineInterface = useOfflineInterface;
8
+
9
+ var _appServiceAlerts = require("@dhis2/app-service-alerts");
10
+
11
+ var _propTypes = _interopRequireDefault(require("prop-types"));
12
+
13
+ var _react = _interopRequireWildcard(require("react"));
14
+
15
+ function _getRequireWildcardCache() { if (typeof WeakMap !== "function") return null; var cache = new WeakMap(); _getRequireWildcardCache = function () { return cache; }; return cache; }
16
+
17
+ function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
18
+
19
+ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
20
+
21
+ // This is to prevent 'offlineInterface could be null' type-checking errors
22
+ const noopOfflineInterface = {
23
+ pwaEnabled: false,
24
+ init: () => () => null,
25
+ startRecording: async () => undefined,
26
+ getCachedSections: async () => [],
27
+ removeSection: async () => false
28
+ };
29
+ const OfflineInterfaceContext = /*#__PURE__*/(0, _react.createContext)(noopOfflineInterface);
30
+
31
+ /**
32
+ * Receives an OfflineInterface instance as a prop (presumably from the app
33
+ * adapter) and provides it as context for other offline tools.
34
+ *
35
+ * On mount, it initializes the offline interface, which (among other things)
36
+ * checks for service worker updates and, if updates are ready, prompts the
37
+ * user with an alert to skip waiting and reload the page to use new content.
38
+ */
39
+ function OfflineInterfaceProvider({
40
+ offlineInterface,
41
+ children
42
+ }) {
43
+ const {
44
+ show
45
+ } = (0, _appServiceAlerts.useAlert)(({
46
+ message
47
+ }) => message, ({
48
+ action,
49
+ onConfirm
50
+ }) => ({
51
+ actions: [{
52
+ label: action,
53
+ onClick: onConfirm
54
+ }],
55
+ permanent: true
56
+ }));
57
+
58
+ _react.default.useEffect(() => {
59
+ // Init returns a tear-down function
60
+ return offlineInterface.init({
61
+ promptUpdate: show
62
+ });
63
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
64
+
65
+
66
+ return /*#__PURE__*/_react.default.createElement(OfflineInterfaceContext.Provider, {
67
+ value: offlineInterface
68
+ }, children);
69
+ }
70
+
71
+ OfflineInterfaceProvider.propTypes = {
72
+ children: _propTypes.default.node,
73
+ offlineInterface: _propTypes.default.shape({
74
+ init: _propTypes.default.func
75
+ })
76
+ };
77
+
78
+ function useOfflineInterface() {
79
+ const offlineInterface = (0, _react.useContext)(OfflineInterfaceContext);
80
+
81
+ if (offlineInterface === undefined) {
82
+ 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.');
83
+ }
84
+
85
+ return offlineInterface;
86
+ }
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.OfflineProvider = OfflineProvider;
7
+
8
+ var _propTypes = _interopRequireDefault(require("prop-types"));
9
+
10
+ var _react = _interopRequireDefault(require("react"));
11
+
12
+ var _cacheableSectionState = require("./cacheable-section-state");
13
+
14
+ var _offlineInterface = require("./offline-interface");
15
+
16
+ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
17
+
18
+ /** A context provider for all the relevant offline contexts */
19
+ function OfflineProvider({
20
+ offlineInterface,
21
+ children
22
+ }) {
23
+ // If an offline interface is not provided, or if one is provided and PWA
24
+ // is not enabled, skip adding context providers
25
+ if (!offlineInterface) {
26
+ return /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, children);
27
+ } // If PWA is not enabled, just init interface to make sure new SW gets
28
+ // activated with code that unregisters SW. Not technically necessary if a
29
+ // killswitch SW takes over, but the killswitch may not always be in use.
30
+ // Then, skip adding any context
31
+
32
+
33
+ if (!offlineInterface.pwaEnabled) {
34
+ offlineInterface.init({
35
+ promptUpdate: ({
36
+ onConfirm
37
+ }) => onConfirm()
38
+ });
39
+ return /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, children);
40
+ }
41
+
42
+ return /*#__PURE__*/_react.default.createElement(_offlineInterface.OfflineInterfaceProvider, {
43
+ offlineInterface: offlineInterface
44
+ }, /*#__PURE__*/_react.default.createElement(_cacheableSectionState.CacheableSectionProvider, null, children));
45
+ }
46
+
47
+ OfflineProvider.propTypes = {
48
+ children: _propTypes.default.node,
49
+ offlineInterface: _propTypes.default.shape({
50
+ init: _propTypes.default.func,
51
+ pwaEnabled: _propTypes.default.bool
52
+ })
53
+ };