@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
@@ -1,2 +1 @@
1
- // todo
2
1
  "use strict";
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.Rerenderer = void 0;
7
+
8
+ var _react = require("@testing-library/react");
9
+
10
+ var _react2 = _interopRequireDefault(require("react"));
11
+
12
+ var _renderCounter = require("../render-counter");
13
+
14
+ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
15
+
16
+ const renderCounts = {};
17
+
18
+ const Rerenderer = () => {
19
+ const [, setState] = _react2.default.useState(true);
20
+
21
+ const toggleState = () => setState(prevState => !prevState);
22
+
23
+ return /*#__PURE__*/_react2.default.createElement(_react2.default.Fragment, null, /*#__PURE__*/_react2.default.createElement("button", {
24
+ onClick: toggleState,
25
+ role: "button"
26
+ }), /*#__PURE__*/_react2.default.createElement(_renderCounter.RenderCounter, {
27
+ id: 'rc1',
28
+ countsObj: renderCounts
29
+ }));
30
+ };
31
+
32
+ exports.Rerenderer = Rerenderer;
33
+ afterEach(() => {
34
+ (0, _renderCounter.resetRenderCounts)(renderCounts);
35
+ });
36
+ it('increments the counter when rerendered', () => {
37
+ (0, _react.render)( /*#__PURE__*/_react2.default.createElement(Rerenderer, null));
38
+ const {
39
+ getByTestId,
40
+ getByRole
41
+ } = _react.screen;
42
+ expect(getByTestId('rc1')).toHaveTextContent('1');
43
+ (0, _react.act)(() => {
44
+ _react.fireEvent.click(getByRole('button'));
45
+ });
46
+ expect(getByTestId('rc1')).toHaveTextContent('2');
47
+ (0, _react.act)(() => {
48
+ _react.fireEvent.click(getByRole('button'));
49
+ });
50
+ expect(getByTestId('rc1')).toHaveTextContent('3');
51
+ });
52
+ it('resets the render counter successfully', () => {
53
+ (0, _react.render)( /*#__PURE__*/_react2.default.createElement(Rerenderer, null));
54
+ expect(_react.screen.getByTestId('rc1')).toHaveTextContent('1');
55
+ });
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.resetRenderCounts = exports.RenderCounter = void 0;
7
+
8
+ var _react = _interopRequireDefault(require("react"));
9
+
10
+ function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
11
+
12
+ const RenderCounter = ({
13
+ id,
14
+ countsObj
15
+ }) => {
16
+ if (!(id in countsObj)) countsObj[id] = 0;
17
+ return /*#__PURE__*/_react.default.createElement("div", {
18
+ "data-testid": id
19
+ }, ++countsObj[id]);
20
+ };
21
+
22
+ exports.RenderCounter = RenderCounter;
23
+
24
+ const resetRenderCounts = renderCounts => Object.keys(renderCounts).forEach(key => renderCounts[key] = 0);
25
+
26
+ exports.resetRenderCounts = resetRenderCounts;
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.mockOfflineInterface = exports.failedMessageRecordingMock = exports.errorRecordingMock = exports.successfulRecordingMock = void 0;
7
+ const successfulRecordingMock = jest.fn().mockImplementation(async ({
8
+ onStarted,
9
+ onCompleted
10
+ } = {}) => {
11
+ // in 100ms, call 'onStarted' callback (allows 'pending' state)
12
+ if (onStarted) setTimeout(onStarted, 100); // in 200ms, call 'onCompleted' callback
13
+
14
+ if (onCompleted) setTimeout(onCompleted, 200); // resolve
15
+
16
+ return Promise.resolve();
17
+ });
18
+ exports.successfulRecordingMock = successfulRecordingMock;
19
+ const errorRecordingMock = jest.fn().mockImplementation(({
20
+ onStarted,
21
+ onError
22
+ } = {}) => {
23
+ // in 100ms, call 'onStarted' callback (allows 'pending' state)
24
+ if (onStarted) setTimeout(onStarted, 100); // in 200ms, call 'onError'
25
+
26
+ setTimeout(() => onError(new Error('test err')), 200); // resolve to signal successful initiation
27
+
28
+ return Promise.resolve();
29
+ });
30
+ exports.errorRecordingMock = errorRecordingMock;
31
+ const failedMessageRecordingMock = jest.fn().mockRejectedValue(new Error('Failed message'));
32
+ exports.failedMessageRecordingMock = failedMessageRecordingMock;
33
+ const mockOfflineInterface = {
34
+ pwaEnabled: true,
35
+ init: jest.fn(),
36
+ startRecording: successfulRecordingMock,
37
+ getCachedSections: jest.fn().mockResolvedValue([]),
38
+ removeSection: jest.fn().mockResolvedValue(true)
39
+ };
40
+ exports.mockOfflineInterface = mockOfflineInterface;
@@ -0,0 +1,327 @@
1
+ function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
2
+
3
+ import { act, fireEvent, render, screen } from '@testing-library/react';
4
+ import React from 'react';
5
+ import { useCacheableSection, CacheableSection } from '../lib/cacheable-section';
6
+ import { OfflineProvider } from '../lib/offline-provider';
7
+ import { RenderCounter, resetRenderCounts } from '../utils/render-counter';
8
+ import { errorRecordingMock, failedMessageRecordingMock, mockOfflineInterface } from '../utils/test-mocks';
9
+ const renderCounts = {};
10
+
11
+ const identity = arg => arg;
12
+
13
+ const TestControls = ({
14
+ id,
15
+ makeRecordingHandler = identity
16
+ }) => {
17
+ const {
18
+ startRecording,
19
+ remove,
20
+ isCached,
21
+ lastUpdated,
22
+ recordingState
23
+ } = useCacheableSection(id);
24
+ return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(RenderCounter, {
25
+ id: "controls-rc-".concat(id),
26
+ countsObj: renderCounts
27
+ }), /*#__PURE__*/React.createElement("button", {
28
+ "data-testid": "start-recording-".concat(id),
29
+ onClick: makeRecordingHandler(startRecording)
30
+ }), /*#__PURE__*/React.createElement("button", {
31
+ "data-testid": "remove-".concat(id),
32
+ onClick: () => {
33
+ remove();
34
+ }
35
+ }), /*#__PURE__*/React.createElement("div", {
36
+ "data-testid": "is-cached-".concat(id)
37
+ }, isCached ? 'yes' : 'no'), /*#__PURE__*/React.createElement("div", {
38
+ "data-testid": "last-updated-".concat(id)
39
+ }, lastUpdated || 'never'), /*#__PURE__*/React.createElement("div", {
40
+ "data-testid": "recording-state-".concat(id)
41
+ }, recordingState));
42
+ };
43
+
44
+ const TestSection = ({
45
+ id,
46
+ children
47
+ }) => /*#__PURE__*/React.createElement(CacheableSection, {
48
+ id: id,
49
+ loadingMask: /*#__PURE__*/React.createElement("div", {
50
+ "data-testid": "loading-mask-".concat(id)
51
+ })
52
+ }, /*#__PURE__*/React.createElement(RenderCounter, {
53
+ id: "section-rc-".concat(id),
54
+ countsObj: renderCounts
55
+ }), children);
56
+
57
+ const TestSingleSection = props => {
58
+ // Props are spread so they can be overwritten
59
+ return /*#__PURE__*/React.createElement(OfflineProvider, _extends({
60
+ offlineInterface: mockOfflineInterface
61
+ }, props), /*#__PURE__*/React.createElement(TestControls, _extends({
62
+ id: '1'
63
+ }, props)), /*#__PURE__*/React.createElement(TestSection, _extends({
64
+ id: '1'
65
+ }, props)));
66
+ }; // Suppress 'act' warning for these tests
67
+
68
+
69
+ const originalError = console.error;
70
+ beforeEach(() => {
71
+ // This is done before each because the 'recording error' test uses its own
72
+ // spy on console.error
73
+ jest.spyOn(console, 'error').mockImplementation((...args) => {
74
+ const pattern = /Warning: An update to .* inside a test was not wrapped in act/;
75
+
76
+ if (typeof args[0] === 'string' && pattern.test(args[0])) {
77
+ return;
78
+ }
79
+
80
+ return originalError.call(console, ...args);
81
+ });
82
+ });
83
+ afterEach(() => {
84
+ jest.clearAllMocks() // This syntax appeases typescript:
85
+ ;
86
+ console.error.mockRestore();
87
+ resetRenderCounts(renderCounts);
88
+ });
89
+ describe('Coordination between useCacheableSection and CacheableSection', () => {
90
+ it('renders in the default state initially', async () => {
91
+ render( /*#__PURE__*/React.createElement(TestSingleSection, null));
92
+ const {
93
+ getByTestId
94
+ } = screen;
95
+ expect(getByTestId(/recording-state/)).toHaveTextContent('default');
96
+ expect(getByTestId(/is-cached/)).toHaveTextContent('no');
97
+ expect(getByTestId(/last-updated/)).toHaveTextContent('never');
98
+ expect(getByTestId(/section-rc/)).toBeInTheDocument();
99
+ expect(getByTestId(/controls-rc/)).toBeInTheDocument();
100
+ });
101
+ it('handles a successful recording', async done => {
102
+ const {
103
+ getByTestId,
104
+ queryByTestId
105
+ } = screen;
106
+
107
+ const onStarted = () => {
108
+ expect(getByTestId(/recording-state/)).toHaveTextContent('recording');
109
+ expect(getByTestId(/loading-mask/)).toBeInTheDocument();
110
+ expect(getByTestId(/section-rc/)).toBeInTheDocument();
111
+ };
112
+
113
+ const onCompleted = () => {
114
+ expect(getByTestId(/recording-state/)).toHaveTextContent('default');
115
+ expect(queryByTestId(/loading-mask/)).not.toBeInTheDocument();
116
+ done();
117
+ };
118
+
119
+ const recordingOptions = {
120
+ onStarted,
121
+ onCompleted
122
+ };
123
+
124
+ const makeRecordingHandler = startRecording => {
125
+ return () => startRecording(recordingOptions);
126
+ };
127
+
128
+ render( /*#__PURE__*/React.createElement(TestSingleSection, {
129
+ makeRecordingHandler: makeRecordingHandler
130
+ }));
131
+ await act(async () => {
132
+ fireEvent.click(getByTestId(/start-recording/));
133
+ }); // At this stage, should be pending
134
+
135
+ expect(getByTestId(/recording-state/)).toHaveTextContent('pending');
136
+ expect(queryByTestId(/section-rc/)).not.toBeInTheDocument();
137
+ expect.assertions(7);
138
+ });
139
+ it('handles a recording that encounters an error', async done => {
140
+ // Suppress the expected error from console (in addition to 'act' warning)
141
+ jest.spyOn(console, 'error').mockImplementation((...args) => {
142
+ const actPattern = /Warning: An update to .* inside a test was not wrapped in act/;
143
+ const errPattern = /Error during recording/;
144
+ const matchesPattern = actPattern.test(args[0]) || errPattern.test(args[0]);
145
+
146
+ if (typeof args[0] === 'string' && matchesPattern) {
147
+ return;
148
+ }
149
+
150
+ return originalError.call(console, ...args);
151
+ });
152
+ const {
153
+ getByTestId,
154
+ queryByTestId
155
+ } = screen;
156
+ const testOfflineInterface = { ...mockOfflineInterface,
157
+ startRecording: errorRecordingMock
158
+ };
159
+
160
+ const onError = () => {
161
+ expect(getByTestId(/recording-state/)).toHaveTextContent('error');
162
+ expect(queryByTestId(/loading-mask/)).not.toBeInTheDocument();
163
+ expect(getByTestId(/section-rc/)).toBeInTheDocument();
164
+ done();
165
+ };
166
+
167
+ const makeRecordingHandler = startRecording => {
168
+ return () => startRecording({
169
+ onError
170
+ });
171
+ };
172
+
173
+ render( /*#__PURE__*/React.createElement(TestSingleSection, {
174
+ offlineInterface: testOfflineInterface,
175
+ makeRecordingHandler: makeRecordingHandler
176
+ }));
177
+ await act(async () => {
178
+ fireEvent.click(getByTestId(/start-recording/));
179
+ });
180
+ expect.assertions(3);
181
+ }); // ! After bumping testing-library versions, something about this test
182
+ // ! causes the following ones to mysteriously fail 😤
183
+
184
+ it.skip('handles an error starting the recording', async done => {
185
+ const {
186
+ getByTestId
187
+ } = screen;
188
+ const testOfflineInterface = { ...mockOfflineInterface,
189
+ startRecording: failedMessageRecordingMock
190
+ };
191
+ const onStarted = jest.fn();
192
+
193
+ const testErrCondition = err => {
194
+ expect(err.message).toBe('Failed message'); // from the mock
195
+
196
+ expect(onStarted).not.toHaveBeenCalled();
197
+ expect(getByTestId(/recording-state/)).toHaveTextContent('default');
198
+ done();
199
+ };
200
+
201
+ const makeRecordingHandler = startRecording => {
202
+ return () => startRecording({
203
+ onStarted
204
+ }).catch(testErrCondition);
205
+ };
206
+
207
+ render( /*#__PURE__*/React.createElement(TestSingleSection, {
208
+ offlineInterface: testOfflineInterface,
209
+ makeRecordingHandler: makeRecordingHandler
210
+ }));
211
+ await act(async () => {
212
+ fireEvent.click(getByTestId(/start-recording/));
213
+ });
214
+ });
215
+ });
216
+
217
+ const TwoTestSections = props =>
218
+ /*#__PURE__*/
219
+ // Props are spread so they can be overwritten (but only on one section)
220
+ React.createElement(OfflineProvider, _extends({
221
+ offlineInterface: mockOfflineInterface
222
+ }, props), /*#__PURE__*/React.createElement(TestControls, _extends({
223
+ id: '1'
224
+ }, props)), /*#__PURE__*/React.createElement(TestSection, _extends({
225
+ id: '1'
226
+ }, props)), /*#__PURE__*/React.createElement(TestControls, {
227
+ id: '2'
228
+ }), /*#__PURE__*/React.createElement(TestSection, {
229
+ id: '2'
230
+ })); // test that other sections don't rerender when one section does
231
+
232
+
233
+ describe('Performant state management', () => {
234
+ it('establishes a pre-recording render count', () => {
235
+ render( /*#__PURE__*/React.createElement(TwoTestSections, null));
236
+ const {
237
+ getByTestId
238
+ } = screen; // Two renders for controls: undefined and 'default' states
239
+
240
+ expect(getByTestId('controls-rc-1')).toHaveTextContent('2');
241
+ expect(getByTestId('controls-rc-2')).toHaveTextContent('2'); // Just one render for sections
242
+
243
+ expect(getByTestId('section-rc-1')).toHaveTextContent('1');
244
+ expect(getByTestId('section-rc-2')).toHaveTextContent('1');
245
+ });
246
+ it('isolates rerenders from other consumers', async done => {
247
+ const {
248
+ getByTestId
249
+ } = screen; // Make assertions
250
+
251
+ const onCompleted = () => {
252
+ // Before refactor: controls components have 6 renders EACH, and
253
+ // sections 1 and 2 have 2 and 1 renders, respectively
254
+ // After refactor, render counts for section that recorded:
255
+ expect(getByTestId('controls-rc-1')).toHaveTextContent('5');
256
+ expect(getByTestId('section-rc-1')).toHaveTextContent('2'); // Section that did not record (should be same as pre-recording):
257
+
258
+ expect(getByTestId('controls-rc-2')).toHaveTextContent('2');
259
+ expect(getByTestId('section-rc-2')).toHaveTextContent('1');
260
+ done();
261
+ };
262
+
263
+ const makeRecordingHandler = startRecording => () => startRecording({
264
+ onCompleted
265
+ });
266
+
267
+ render( /*#__PURE__*/React.createElement(TwoTestSections, {
268
+ makeRecordingHandler: makeRecordingHandler
269
+ }));
270
+ await act(async () => {
271
+ fireEvent.click(getByTestId('start-recording-1'));
272
+ });
273
+ expect.assertions(4);
274
+ });
275
+ });
276
+ describe('useCacheableSection can be used inside a child of CacheableSection', () => {
277
+ const ChildTest = props => {
278
+ // Props are spread so they can be overwritten
279
+ return /*#__PURE__*/React.createElement(OfflineProvider, _extends({
280
+ offlineInterface: mockOfflineInterface
281
+ }, props), /*#__PURE__*/React.createElement(TestSection, _extends({
282
+ id: '1'
283
+ }, props), /*#__PURE__*/React.createElement(TestControls, _extends({
284
+ id: '1'
285
+ }, props))));
286
+ };
287
+
288
+ it('handles a successful recording', async done => {
289
+ const {
290
+ getByTestId,
291
+ queryByTestId
292
+ } = screen;
293
+
294
+ const onStarted = () => {
295
+ expect(getByTestId(/recording-state/)).toHaveTextContent('recording');
296
+ expect(getByTestId(/loading-mask/)).toBeInTheDocument();
297
+ expect(getByTestId(/section-rc/)).toBeInTheDocument();
298
+ };
299
+
300
+ const onCompleted = () => {
301
+ expect(getByTestId(/recording-state/)).toHaveTextContent('default');
302
+ expect(queryByTestId(/loading-mask/)).not.toBeInTheDocument();
303
+ done();
304
+ };
305
+
306
+ const recordingOptions = {
307
+ onStarted,
308
+ onCompleted
309
+ };
310
+
311
+ const makeRecordingHandler = startRecording => {
312
+ return () => startRecording(recordingOptions);
313
+ };
314
+
315
+ render( /*#__PURE__*/React.createElement(ChildTest, {
316
+ makeRecordingHandler: makeRecordingHandler
317
+ }));
318
+ await act(async () => {
319
+ fireEvent.click(getByTestId(/start-recording/));
320
+ }); // At this stage, should be pending
321
+ // - In this test case, 'controls' should not be rendered
322
+
323
+ expect(queryByTestId(/recording-state/)).not.toBeInTheDocument();
324
+ expect(queryByTestId(/section-rc/)).not.toBeInTheDocument();
325
+ expect.assertions(7);
326
+ });
327
+ });