diff --git a/docs/advanced/offline.md b/docs/advanced/offline.md index 45d10ad5..69b73210 100644 --- a/docs/advanced/offline.md +++ b/docs/advanced/offline.md @@ -1,5 +1,141 @@ # Offline tools +The app platform provides some support for PWA features, including a `manifest.json` file for installability and service worker which can provide offline caching. In addition to those features, the app runtime provides support for "cacheable sections", which are sections of an app that can be individually cached on-demand. The [`useCacheableSection`](#usecacheablesection-api) hook and the [`CacheableSection`](#cacheablesection-api) component provide the controls for the section and the wrapper for the section, respectively. The [`useCachedSections`](#usecachedsections-api) hook returns a list of sections that are stored in the cache and a function that can delete them. + +There is also a [`useOnlineStatus`](#online-status) hook which returns the online or offline status of the client. + +To see a good example of these functions' APIs and their usage, see `SectionWrapper.js` in the [PWA example app](https://github.com/dhis2/app-platform/tree/master/examples/pwa-app/src/components/SectionWrapper.js) in the platform repository. + +## Cacheable sections + +> This feature can only be used when PWA is enabled in `d2.config.js`. See the [App Platform docs](https://platform.dhis2.nu/#/pwa/pwa) for more information. + +These features are supported by an `` component which the app platform provides to the app. + +### How it works + +Cacheable sections enable sections of an app to be individually cached offline on demand. Using the `CacheableSection` wrapper and the `useCacheableSection` hook, when a user requests a section to be cached for offline use, the section's component tree will rerender, and the app's service worker will listen to all the network traffic for the component to cache it offline. To avoid caching that components' data before a user requests to do so, you can use the [URL filters feature](https://platform.dhis2.nu/#/pwa/pwa?id=opting-in) in `d2.config.js`. + +Note that, without using these features, an app using offline caching will cache all the data that is requested by user as they use the app without needing to use cacheable sections. + +Keep an eye out for this feature in use in the Dashboards app coming soon! + +### Usage + +Wrap the component to be cached in a `CacheableSection` hook, providing an `id` and `loadingMask` prop. The loading mask should block the screen from interaction, and it will be rendered while the component is in 'recording mode'. Note that the `useCacheableSection` hook _does not need to be used in the same component_ as the ``, they just need to use the same `id`. There is an example of this in the file linked below. + +Here is an example of the basic usage: + +```jsx +import { CacheableSection, useCacheableSection } from '@dhis2/app-runtime' +import { Button, Layer, CenteredContent, CircularLoader } from '@dhis2/ui' +import { Dashboard } from './Dashboard' + +/** An example loading mask */ +const LoadingMask = () => ( + + + + + +) + +export function CacheableSectionWrapper({ id }) { + const { startRecording, isCached, lastUpdated } = useCacheableSection(id) + + return ( +
+

{isCached ? `Last updated: ${lastUpdated}` : 'Not cached'}

+
+ ) +} +``` + +#### `CacheableSection` API + +| Prop | Type | Required? | Description | +| ------------- | --------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `children` | Component | Yes | Section that will be cached upon recording | +| `id` | String | Yes | ID of the section to be cached. Should match the ID used in the `useCacheableSection` hook. | +| `loadingMask` | Component | Yes | A UI mask that should block the screen from interaction. While the component is rerendering and recording, this mask will be rendered to block user interaction which may interfere with the recorded data. | + +#### `useCacheableSection` API + +```jsx +import { useCacheableSections } from '@dhis2/app-runtime' + +function DemoComponent() { + const { + startRecording, + remove, + lastUpdated, + isCached, + recordingState, + } = useCacheableSection(id) +} +``` + +`useCacheableSection` takes an `id` parameter (a string) and returns an object with the following properties: + +| Property | Type | Description | +| ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `startRecording` | Function | Initiates recording of a cacheable section's data for offline use. Causes a `` component with a matching ID to rerender with a loading mask based on the recording state to initiate the network requests. See the full API in the [`startRecording`](#startrecording-api) section below. | +| `remove` | Function | Removes this section from offline storage. Returns a promise that resolves to `true` if the section was successfully removed or `false` if that section was not found in offline storage. | +| `lastUpdated` | Date | A timestamp of the last time this section was successfully recorded. | +| `isCached` | Boolean | `true` if this section is in offline storage; Provided for convenience. | +| `recordingState` | String | One of `'default'`, `'pending'`, `'recording'`, or `'error'`. Under the hood, the `CacheableSection` component changes how its children are rendered based on the states. They are returned here in case an app wants to change UI or behavior based on the recording state. | + +#### `startRecording` API + +The `startRecording` function returned by `useCacheableSection` returns a promise that resolves if 'start recording' signal is sent successfully or rejects if there is an error with the offline interface initiating recording. It accepts an `options` parameter with the following optional properties: + +| Property | Type | Default | Description | +| ----------------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `onStarted` | Function | | A callback to be called once a recording has started and the service worker is listening to network requests. Receives no arguments | +| `onCompleted` | Function | | A callback to be called when the recording has completed successfully. Receives no arguments | +| `onError` | Function | | A callback to be called in the case of an error during recording. Receives an `error` object as an argument | +| `recordingTimeoutDelay` | Number | `1000` | The time (in ms) to wait after all pending network requests have finished before stopping a recording. If a user's device is slow, and there might be long pauses between requests that are necessary for that section to run offline, this number may need to be increased from its default to prevent recording from stopping prematurely. | + +Example: + +```jsx +import { useCacheableSection } from '@dhis2/app-runtime' +import { Button } from '@dhis2/ui' + +function StartRecordingButton({ id }) { + const { startRecording } = useCacheableSection(id) + + function handleStartRecording() { + startRecording({ + onStarted: () => console.log('Recording started'), + onCompleted: () => console.log('Recording completed'), + onError: err => console.error(err), + recordingTimeoutDelay: 1000, // the default + }) + .then(() => console.log('startRecording signal sent successfully')) + .catch(err => + console.error(`Error when starting recording: ${err}`) + ) + } + + return +} +``` + +#### `useCachedSections` API + +The `useCachedSections` hook returns a list of all the sections that are cached, which can be useful if an app needs to manage that whole list at once. It takes no arguments and it returns an object with the following properties: + +| Property | Type | Description | +| -------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cachedSections` | Object | An object of cached sections' statuses, where the keys are the section IDs and the values are objects with a `lastUpdated` property that holds a `Date` object reflecting the time this section was last updated. | +| `removeById` | Function | Receives an `id` parameter and attempts to remove the section with that ID from the offline cache. If successful, updates the cached sections list. Returns a promise that resolves to `true` if that section is successfully removed or `false` if a section with that ID was not found. | +| `syncCachedSections` | Function | Syncs the list of cached sections with the list in IndexedDB. Returns a promise. This is handled by the `removeById` function and is probably not necessary to use in most applications. | + ## Online status The `useOnlineStatus` returns the client's online or offline status. It debounces the values by default to prevent rapid changes of any UI elements that depend on the online status. diff --git a/examples/cra/yarn.lock b/examples/cra/yarn.lock index 49c81c49..e2c88e5f 100644 --- a/examples/cra/yarn.lock +++ b/examples/cra/yarn.lock @@ -1047,24 +1047,26 @@ integrity sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg== "@dhis2/app-runtime@file:../../runtime": - version "2.8.0" + version "2.10.0-pwa.4" dependencies: - "@dhis2/app-service-alerts" "2.8.0" - "@dhis2/app-service-config" "2.8.0" - "@dhis2/app-service-data" "2.8.0" - "@dhis2/app-service-offline" "2.8.0" + "@dhis2/app-service-alerts" "2.10.0-pwa.4" + "@dhis2/app-service-config" "2.10.0-pwa.4" + "@dhis2/app-service-data" "2.10.0-pwa.4" + "@dhis2/app-service-offline" "2.10.0-pwa.4" -"@dhis2/app-service-alerts@2.8.0", "@dhis2/app-service-alerts@file:../../services/alerts": - version "2.8.0" +"@dhis2/app-service-alerts@2.10.0-pwa.4", "@dhis2/app-service-alerts@file:../../services/alerts": + version "2.10.0-pwa.4" -"@dhis2/app-service-config@2.8.0", "@dhis2/app-service-config@file:../../services/config": - version "2.8.0" +"@dhis2/app-service-config@2.10.0-pwa.4", "@dhis2/app-service-config@file:../../services/config": + version "2.10.0-pwa.4" -"@dhis2/app-service-data@2.8.0", "@dhis2/app-service-data@file:../../services/data": - version "2.8.0" +"@dhis2/app-service-data@2.10.0-pwa.4", "@dhis2/app-service-data@file:../../services/data": + version "2.10.0-pwa.4" -"@dhis2/app-service-offline@2.8.0", "@dhis2/app-service-offline@file:../../services/offline": - version "2.8.0" +"@dhis2/app-service-offline@2.10.0-pwa.4", "@dhis2/app-service-offline@file:../../services/offline": + version "2.10.0-pwa.4" + dependencies: + lodash "^4.17.21" "@hapi/address@2.x.x": version "2.1.4" @@ -6507,6 +6509,11 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + loglevel@^1.6.6: version "1.6.8" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.8.tgz#8a25fb75d092230ecd4457270d80b54e28011171" diff --git a/examples/query-playground/yarn.lock b/examples/query-playground/yarn.lock index b88df743..81a56e4f 100644 --- a/examples/query-playground/yarn.lock +++ b/examples/query-playground/yarn.lock @@ -1782,25 +1782,36 @@ dependencies: moment "^2.24.0" -"@dhis2/app-runtime@*", "@dhis2/app-runtime@^2.2.2", "@dhis2/app-runtime@file:../../runtime": +"@dhis2/app-runtime@*": version "2.8.0" + resolved "https://registry.yarnpkg.com/@dhis2/app-runtime/-/app-runtime-2.8.0.tgz#83ca6e96c299686ee72eea3e1825e04aa53cd5d2" + integrity sha512-Ru6x9L61fD7ITzVaxFqx88kV5/ypB9xSr8nHgRj4EE/kHl/aVejXuwnSS2OIWh80J3mtD1dpNRN/GJ8o0x0HYg== dependencies: "@dhis2/app-service-alerts" "2.8.0" "@dhis2/app-service-config" "2.8.0" "@dhis2/app-service-data" "2.8.0" - "@dhis2/app-service-offline" "2.8.0" -"@dhis2/app-service-alerts@2.8.0", "@dhis2/app-service-alerts@file:../../services/alerts": - version "2.8.0" +"@dhis2/app-runtime@^2.2.2", "@dhis2/app-runtime@file:../../runtime": + version "2.10.0-pwa.4" + dependencies: + "@dhis2/app-service-alerts" "2.10.0-pwa.4" + "@dhis2/app-service-config" "2.10.0-pwa.4" + "@dhis2/app-service-data" "2.10.0-pwa.4" + "@dhis2/app-service-offline" "2.10.0-pwa.4" -"@dhis2/app-service-config@2.8.0", "@dhis2/app-service-config@file:../../services/config": - version "2.8.0" +"@dhis2/app-service-alerts@2.10.0-pwa.4", "@dhis2/app-service-alerts@2.8.0", "@dhis2/app-service-alerts@file:../../services/alerts": + version "2.10.0-pwa.4" -"@dhis2/app-service-data@2.8.0", "@dhis2/app-service-data@file:../../services/data": - version "2.8.0" +"@dhis2/app-service-config@2.10.0-pwa.4", "@dhis2/app-service-config@2.8.0", "@dhis2/app-service-config@file:../../services/config": + version "2.10.0-pwa.4" -"@dhis2/app-service-offline@2.8.0", "@dhis2/app-service-offline@file:../../services/offline": - version "2.8.0" +"@dhis2/app-service-data@2.10.0-pwa.4", "@dhis2/app-service-data@2.8.0", "@dhis2/app-service-data@file:../../services/data": + version "2.10.0-pwa.4" + +"@dhis2/app-service-offline@2.10.0-pwa.4", "@dhis2/app-service-offline@file:../../services/offline": + version "2.10.0-pwa.4" + dependencies: + lodash "^4.17.21" "@dhis2/app-shell@5.2.0": version "5.2.0" @@ -8254,6 +8265,11 @@ lodash@^4.17.19: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + loglevel@^1.6.6: version "1.6.7" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.7.tgz#b3e034233188c68b889f5b862415306f565e2c56" diff --git a/package.json b/package.json index 8555c584..73a31ade 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "2.10.0", + "version": "2.10.0-pwa.4", "description": "A singular runtime dependency for applications on the DHIS2 platform", "repository": "https://github.com/dhis2/app-runtime.git", "author": "Austin McGee ", @@ -57,5 +57,8 @@ "name": "DHIS2 Application Runtime", "description": "A singular runtime dependency for applications on the [DHIS2 platform](https://platform.dhis2.nu)" } + }, + "resolutions": { + "@dhis2/app-runtime": "2.10.0-pwa.4" } } diff --git a/runtime/package.json b/runtime/package.json index be1c2f24..090badb2 100644 --- a/runtime/package.json +++ b/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@dhis2/app-runtime", "description": "A singular runtime dependency for applications on the DHIS2 platform", - "version": "2.10.0", + "version": "2.10.0-pwa.4", "main": "./build/cjs/index.js", "module": "./build/es/index.js", "types": "./build/types/index.d.ts", @@ -23,10 +23,10 @@ "build/**" ], "dependencies": { - "@dhis2/app-service-alerts": "2.10.0", - "@dhis2/app-service-config": "2.10.0", - "@dhis2/app-service-data": "2.10.0", - "@dhis2/app-service-offline": "2.10.0" + "@dhis2/app-service-alerts": "2.10.0-pwa.4", + "@dhis2/app-service-config": "2.10.0-pwa.4", + "@dhis2/app-service-data": "2.10.0-pwa.4", + "@dhis2/app-service-offline": "2.10.0-pwa.4" }, "peerDependencies": { "prop-types": "^15.7.2", diff --git a/runtime/src/Provider.tsx b/runtime/src/Provider.tsx index 4e2e2d82..19ad016d 100644 --- a/runtime/src/Provider.tsx +++ b/runtime/src/Provider.tsx @@ -2,16 +2,26 @@ import { AlertsProvider } from '@dhis2/app-service-alerts' import { ConfigProvider } from '@dhis2/app-service-config' import { Config } from '@dhis2/app-service-config/build/types/types' import { DataProvider } from '@dhis2/app-service-data' +import { OfflineProvider } from '@dhis2/app-service-offline' import React from 'react' type ProviderInput = { config: Config children: React.ReactNode + offlineInterface?: any // temporary until offline service has types } -export const Provider = ({ config, children }: ProviderInput) => ( +export const Provider = ({ + config, + children, + offlineInterface, +}: ProviderInput) => ( - {children} + + + {children} + + ) diff --git a/runtime/src/index.ts b/runtime/src/index.ts index 1aaa87fd..341798b9 100644 --- a/runtime/src/index.ts +++ b/runtime/src/index.ts @@ -12,6 +12,11 @@ export { useConfig } from '@dhis2/app-service-config' export { useAlerts, useAlert } from '@dhis2/app-service-alerts' -export { useOnlineStatus } from '@dhis2/app-service-offline' +export { + useOnlineStatus, + useCacheableSection, + CacheableSection, + useCachedSections, +} from '@dhis2/app-service-offline' export { Provider } from './Provider' diff --git a/services/alerts/package.json b/services/alerts/package.json index 34368bcb..3a332049 100644 --- a/services/alerts/package.json +++ b/services/alerts/package.json @@ -1,6 +1,6 @@ { "name": "@dhis2/app-service-alerts", - "version": "2.10.0", + "version": "2.10.0-pwa.4", "main": "./build/cjs/index.js", "module": "./build/es/index.js", "types": "./build/types/index.d.ts", diff --git a/services/config/package.json b/services/config/package.json index f5949964..2b7789f4 100644 --- a/services/config/package.json +++ b/services/config/package.json @@ -1,6 +1,6 @@ { "name": "@dhis2/app-service-config", - "version": "2.10.0", + "version": "2.10.0-pwa.4", "main": "./build/cjs/index.js", "module": "./build/es/index.js", "types": "build/types/index.d.ts", diff --git a/services/data/package.json b/services/data/package.json index 7021d707..68592622 100644 --- a/services/data/package.json +++ b/services/data/package.json @@ -1,6 +1,6 @@ { "name": "@dhis2/app-service-data", - "version": "2.10.0", + "version": "2.10.0-pwa.4", "main": "./build/cjs/index.js", "module": "./build/es/index.js", "types": "build/types/index.d.ts", @@ -22,7 +22,7 @@ "build/**" ], "peerDependencies": { - "@dhis2/app-service-config": "2.10.0", + "@dhis2/app-service-config": "2.10.0-pwa.4", "prop-types": "^15.7.2", "react": "^16.8", "react-dom": "^16.8" diff --git a/services/offline/package.json b/services/offline/package.json index 7cca41b6..6796b08e 100644 --- a/services/offline/package.json +++ b/services/offline/package.json @@ -1,7 +1,7 @@ { "name": "@dhis2/app-service-offline", "description": "A runtime service for online/offline detection and offline caching", - "version": "2.10.0", + "version": "2.10.0-pwa.4", "main": "./build/cjs/index.js", "module": "./build/es/index.js", "types": "build/types/index.d.ts", @@ -33,7 +33,7 @@ "coverage": "yarn test --coverage" }, "peerDependencies": { - "@dhis2/app-service-alerts": "2.10.0", + "@dhis2/app-service-alerts": "^2.10.0-pwa.4", "prop-types": "^15.7.2", "react": "^16.8.6", "react-dom": "^16.8.6" diff --git a/services/offline/src/__tests__/integration.test.tsx b/services/offline/src/__tests__/integration.test.tsx new file mode 100644 index 00000000..cc7427af --- /dev/null +++ b/services/offline/src/__tests__/integration.test.tsx @@ -0,0 +1,330 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import { + useCacheableSection, + CacheableSection, + CacheableSectionStartRecording, +} from '../lib/cacheable-section' +import { OfflineProvider } from '../lib/offline-provider' +import { RenderCounter, resetRenderCounts } from '../utils/render-counter' +import { + errorRecordingMock, + failedMessageRecordingMock, + mockOfflineInterface, +} from '../utils/test-mocks' + +const renderCounts = {} + +const identity = (arg: any) => arg + +const TestControls = ({ + id, + makeRecordingHandler = identity, +}: { + id: string + makeRecordingHandler?: (cb?: any) => () => Promise +}) => { + const { + startRecording, + remove, + isCached, + lastUpdated, + recordingState, + } = useCacheableSection(id) + + return ( + <> + +