diff --git a/src/components/DashboardPluginWrapper/DashboardPluginWrapper.js b/src/components/DashboardPluginWrapper/DashboardPluginWrapper.js
new file mode 100644
index 000000000..e21986a5e
--- /dev/null
+++ b/src/components/DashboardPluginWrapper/DashboardPluginWrapper.js
@@ -0,0 +1,96 @@
+import {
+ useCacheableSection,
+ CacheableSection,
+ useConfig,
+} from '@dhis2/app-runtime'
+import { CenteredContent, CircularLoader, CssVariables, Layer } from '@dhis2/ui'
+import PropTypes from 'prop-types'
+import React, { useEffect } from 'react'
+import { getPWAInstallationStatus } from '../../modules/getPWAInstallationStatus.js'
+
+const LoadingMask = () => {
+ return (
+
+
+
+
+
+ )
+}
+
+const CacheableSectionWrapper = ({ id, children, isParentCached }) => {
+ const { startRecording, isCached, remove } = useCacheableSection(id)
+
+ useEffect(() => {
+ if (isParentCached && !isCached) {
+ startRecording({ onError: console.error })
+ } else if (!isParentCached && isCached) {
+ // Synchronize cache state on load or prop update
+ // -- a back-up to imperative `removeCachedData`
+ remove()
+ }
+ }, [isCached, isParentCached, remove, startRecording])
+
+ return (
+ }>
+ {children}
+
+ )
+}
+
+CacheableSectionWrapper.propTypes = {
+ children: PropTypes.node,
+ id: PropTypes.string,
+ isParentCached: PropTypes.bool,
+}
+
+export const DashboardPluginWrapper = ({
+ onInstallationStatusChange,
+ children,
+ cacheId,
+ isParentCached,
+ ...props
+}) => {
+ const { pwaEnabled } = useConfig()
+
+ useEffect(() => {
+ // Get & send PWA installation status now
+ getPWAInstallationStatus({
+ onStateChange: onInstallationStatusChange,
+ }).then(onInstallationStatusChange)
+ }, [onInstallationStatusChange])
+
+ return props ? (
+
+ {pwaEnabled ? (
+
+ {children(props)}
+
+ ) : (
+ children(props)
+ )}
+
+
+ ) : null
+}
+
+DashboardPluginWrapper.defaultProps = {
+ isParentCached: false,
+ onInstallationStatusChange: Function.prototype,
+}
+
+DashboardPluginWrapper.propTypes = {
+ cacheId: PropTypes.string,
+ children: PropTypes.func,
+ isParentCached: PropTypes.bool,
+ onInstallationStatusChange: PropTypes.func,
+}
diff --git a/src/components/Toolbar/HoverMenuBar/HoverMenuDropdown.js b/src/components/Toolbar/HoverMenuBar/HoverMenuDropdown.js
index fbcdf146c..1999647a6 100644
--- a/src/components/Toolbar/HoverMenuBar/HoverMenuDropdown.js
+++ b/src/components/Toolbar/HoverMenuBar/HoverMenuDropdown.js
@@ -1,5 +1,4 @@
-import { Popper } from '@dhis2-ui/popper'
-import { Portal } from '@dhis2-ui/portal'
+import { Popper, Portal } from '@dhis2/ui'
import cx from 'classnames'
import PropTypes from 'prop-types'
import React, { useRef } from 'react'
diff --git a/src/components/Toolbar/HoverMenuBar/HoverMenuList.js b/src/components/Toolbar/HoverMenuBar/HoverMenuList.js
index 6709977af..11f50796f 100644
--- a/src/components/Toolbar/HoverMenuBar/HoverMenuList.js
+++ b/src/components/Toolbar/HoverMenuBar/HoverMenuList.js
@@ -1,4 +1,4 @@
-import { colors, elevations, spacers } from '@dhis2/ui-constants'
+import { colors, elevations, spacers } from '@dhis2/ui'
import PropTypes from 'prop-types'
import React, { createContext, useCallback, useContext, useState } from 'react'
import { useHoverMenubarContext } from './HoverMenuBar.js'
diff --git a/src/components/Toolbar/HoverMenuBar/HoverMenuListItem.js b/src/components/Toolbar/HoverMenuBar/HoverMenuListItem.js
index b336d7e87..2ad12d02b 100644
--- a/src/components/Toolbar/HoverMenuBar/HoverMenuListItem.js
+++ b/src/components/Toolbar/HoverMenuBar/HoverMenuListItem.js
@@ -1,6 +1,4 @@
-import { IconChevronRight24 } from '@dhis2/ui-icons'
-import { Popper } from '@dhis2-ui/popper'
-import { Portal } from '@dhis2-ui/portal'
+import { IconChevronRight24, Popper, Portal } from '@dhis2/ui'
import cx from 'classnames'
import PropTypes from 'prop-types'
import React, { useRef } from 'react'
diff --git a/src/components/Toolbar/HoverMenuBar/HoverMenuListItem.styles.js b/src/components/Toolbar/HoverMenuBar/HoverMenuListItem.styles.js
index 3e3d42197..31e0f12eb 100644
--- a/src/components/Toolbar/HoverMenuBar/HoverMenuListItem.styles.js
+++ b/src/components/Toolbar/HoverMenuBar/HoverMenuListItem.styles.js
@@ -1,4 +1,4 @@
-import { colors, spacers } from '@dhis2/ui-constants'
+import { colors, spacers } from '@dhis2/ui'
import css from 'styled-jsx/css'
export default css`
diff --git a/src/components/Toolbar/MenuButton.styles.js b/src/components/Toolbar/MenuButton.styles.js
index 683567cb8..63982d282 100644
--- a/src/components/Toolbar/MenuButton.styles.js
+++ b/src/components/Toolbar/MenuButton.styles.js
@@ -1,4 +1,4 @@
-import { colors, spacers, theme } from '@dhis2/ui-constants'
+import { colors, spacers, theme } from '@dhis2/ui'
import css from 'styled-jsx/css'
export default css`
diff --git a/src/components/Toolbar/Toolbar.js b/src/components/Toolbar/Toolbar.js
index f6dcce5e7..8f4cd10a6 100644
--- a/src/components/Toolbar/Toolbar.js
+++ b/src/components/Toolbar/Toolbar.js
@@ -1,4 +1,4 @@
-import { colors } from '@dhis2/ui-constants'
+import { colors } from '@dhis2/ui'
import PropTypes from 'prop-types'
import React from 'react'
diff --git a/src/components/Toolbar/ToolbarSidebar.js b/src/components/Toolbar/ToolbarSidebar.js
index 948a9fbed..2a0b4935c 100644
--- a/src/components/Toolbar/ToolbarSidebar.js
+++ b/src/components/Toolbar/ToolbarSidebar.js
@@ -1,4 +1,4 @@
-import { colors } from '@dhis2/ui-constants'
+import { colors } from '@dhis2/ui'
import cx from 'classnames'
import PropTypes from 'prop-types'
import React from 'react'
diff --git a/src/components/Toolbar/UpdateButton.js b/src/components/Toolbar/UpdateButton.js
index 5ac678afa..ee3b2c6ef 100644
--- a/src/components/Toolbar/UpdateButton.js
+++ b/src/components/Toolbar/UpdateButton.js
@@ -1,7 +1,5 @@
import i18n from '@dhis2/d2-i18n'
-import { colors } from '@dhis2/ui-constants'
-import { IconSync16 } from '@dhis2/ui-icons'
-import { CircularLoader } from '@dhis2-ui/loader'
+import { CircularLoader, IconSync16, colors } from '@dhis2/ui'
import PropTypes from 'prop-types'
import React from 'react'
import menuButtonStyles from './MenuButton.styles.js'
diff --git a/src/index.js b/src/index.js
index 7f4c2f2eb..0d3270c5f 100644
--- a/src/index.js
+++ b/src/index.js
@@ -49,6 +49,8 @@ export {
export * from './components/RichText/index.js'
+export { DashboardPluginWrapper } from './components/DashboardPluginWrapper/DashboardPluginWrapper.js'
+
// Api
export { default as Analytics } from './api/analytics/Analytics.js'
diff --git a/src/modules/getPWAInstallationStatus.js b/src/modules/getPWAInstallationStatus.js
new file mode 100644
index 000000000..028cd10f2
--- /dev/null
+++ b/src/modules/getPWAInstallationStatus.js
@@ -0,0 +1,63 @@
+export const INSTALLATION_STATES = {
+ READY: 'READY',
+ INSTALLING: 'INSTALLING',
+}
+
+function handleInstallingWorker({ installingWorker, onStateChange }) {
+ installingWorker.onstatechange = () => {
+ if (installingWorker.state === 'activated') {
+ // ... and update state to 'ready'
+ onStateChange(INSTALLATION_STATES.READY)
+ }
+ }
+}
+
+/**
+ * Gets the current installation state of the PWA features, which is intended
+ * to be reported from this plugin to the parent app to indicate that the
+ * static assets are cached and ready to be accessed locally instead of over
+ * the network.
+ *
+ * Returns either READY, INSTALLING, or `null` for not installed/won't install
+ */
+export async function getPWAInstallationStatus({ onStateChange }) {
+ if (!navigator.serviceWorker) {
+ // Nothing to do here
+ return null
+ }
+
+ const registration = await navigator.serviceWorker.getRegistration()
+ if (!registration) {
+ // This shouldn't happen since this is a PWA app, but return null
+ return null
+ }
+
+ if (registration.active) {
+ return INSTALLATION_STATES.READY
+ }
+ // note that 'registration.waiting' is skipped - it implies there's an active one
+ if (registration.installing) {
+ handleInstallingWorker({
+ installingWorker: registration.installing,
+ onStateChange,
+ })
+ return INSTALLATION_STATES.INSTALLING
+ }
+
+ // It shouldn't normally be possible to get here, but just in case,
+ // listen for installations
+ registration.onupdatefound = () => {
+ // update state for this plugin to 'installing'
+ onStateChange(INSTALLATION_STATES.INSTALLING)
+
+ // also listen for the installing worker to become active
+ const installingWorker = registration.installing
+ if (!installingWorker) {
+ return
+ }
+ handleInstallingWorker({ installingWorker, onStateChange })
+ }
+
+ // and in the mean time, return null to show 'not installed'
+ return null
+}