From fcbe234bf73783b6ba46f5f075d46733c87696eb Mon Sep 17 00:00:00 2001 From: Kai Vandivier <49666798+KaiVandivier@users.noreply.github.com> Date: Mon, 3 Mar 2025 14:06:14 +0100 Subject: [PATCH] feat: watch for pwa updates in plugin (#5) * feat: track PWA update status from global shell * fix: new deps * chore: add typescript to project * fix: update to latest @dhis2/pwa changes * chore: a bit of clean-up * refactor: remove old "pluginified app" way for PWA updates * refactor: clean up which update state is used * chore: edge case loop warning * chore: comment * chore: remove unused patch --- package.json | 11 +- patches/@dhis2+app-adapter+12.3.0.patch | 591 ------------------ .../@dhis2+app-service-plugin+3.13.2.patch | 97 --- patches/@dhis2+pwa+12.3.0.patch | 512 ++++++++++++++- src/App.jsx | 73 +-- ...UpdateModal.jsx => ConfirmUpdateModal.tsx} | 18 +- src/components/ConnectedHeaderbar.jsx | 95 ++- src/components/PluginLoader.jsx | 38 +- src/lib/clientPWAUpdateState.jsx | 98 +++ tsconfig.json | 29 + yarn.lock | 91 +-- 11 files changed, 783 insertions(+), 870 deletions(-) delete mode 100644 patches/@dhis2+app-adapter+12.3.0.patch delete mode 100644 patches/@dhis2+app-service-plugin+3.13.2.patch rename src/components/{ConfirmUpdateModal.jsx => ConfirmUpdateModal.tsx} (84%) create mode 100644 src/lib/clientPWAUpdateState.jsx create mode 100644 tsconfig.json diff --git a/package.json b/package.json index 916be5f..3c5bb40 100644 --- a/package.json +++ b/package.json @@ -12,27 +12,32 @@ "postinstall": "patch-package", "bd": "yarn build && yarn deploy", "del-adapter": "rm -rf node_modules/@dhis2/app-adapter/build", + "del-pwa": "rm -rf node_modules/@dhis2/pwa/build", "del-headerbar": "rm -rf node_modules/@dhis2-ui/header-bar/build", "del-offline": "rm -rf node_modules/@dhis2/app-service-offline/build", "del-plugin": "rm -rf node_modules/@dhis2/app-service-plugin/build", "del-nm-cache": "rm -rf .d2/shell/node_modules/.cache", "cp-adapter": "cp -r ../app-platform/adapter/build node_modules/@dhis2/app-adapter/build", + "cp-pwa": "cp -r ../app-platform/pwa/build node_modules/@dhis2/pwa/build", "cp-headerbar": "cp -r ../ui/components/header-bar/build node_modules/@dhis2-ui/header-bar/build", "cp-offline": "cp -r ../app-runtime/services/offline/build node_modules/@dhis2/app-service-offline/build", "cp-plugin": "cp -r ../app-runtime/services/plugin/build node_modules/@dhis2/app-service-plugin/build", "import-adapter": "yarn del-adapter && yarn del-nm-cache && yarn cp-adapter && yarn patch-package @dhis2/app-adapter", + "import-pwa": "yarn del-pwa && yarn del-nm-cache && yarn cp-pwa && yarn patch-package @dhis2/pwa", "import-headerbar": "yarn del-headerbar && yarn del-nm-cache && yarn cp-headerbar && yarn patch-package @dhis2-ui/header-bar", "import-plugin": "yarn del-plugin && yarn del-nm-cache && yarn cp-plugin && yarn patch-package @dhis2/app-service-plugin", "import-offline": "yarn del-offline && yarn del-nm-cache && yarn cp-offline" }, "devDependencies": { "@dhis2/cli-app-scripts": "^12.3.0", - "@dhis2/cli-style": "^10.7.5", + "@dhis2/cli-style": "^10.7.6", + "@types/react": "^19.0.10", "patch-package": "^8.0.0", - "postinstall-postinstall": "^2.1.0" + "postinstall-postinstall": "^2.1.0", + "typescript": "^5.7.3" }, "dependencies": { - "@dhis2/app-runtime": "^3.13.2", + "@dhis2/app-runtime": "^3.14.0", "@dhis2/pwa": "^12.3.0", "@dhis2/ui": "^10.1.13", "react-router-dom": "^6.22.0" diff --git a/patches/@dhis2+app-adapter+12.3.0.patch b/patches/@dhis2+app-adapter+12.3.0.patch deleted file mode 100644 index 51f16e6..0000000 --- a/patches/@dhis2+app-adapter+12.3.0.patch +++ /dev/null @@ -1,591 +0,0 @@ -diff --git a/node_modules/@dhis2/app-adapter/build/cjs/components/AppWrapper.js b/node_modules/@dhis2/app-adapter/build/cjs/components/AppWrapper.js -index 3673f9a..8d830fa 100644 ---- a/node_modules/@dhis2/app-adapter/build/cjs/components/AppWrapper.js -+++ b/node_modules/@dhis2/app-adapter/build/cjs/components/AppWrapper.js -@@ -13,6 +13,7 @@ var _Alerts = require("./Alerts.js"); - var _ConnectedHeaderBar = require("./ConnectedHeaderBar.js"); - var _ErrorBoundary = require("./ErrorBoundary.js"); - var _LoadingMask = require("./LoadingMask.js"); -+var _PluginPWAUpdateManager = require("./PluginPWAUpdateManager.js"); - var _AppWrapperStyle = require("./styles/AppWrapper.style.js"); - function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } - const AppWrapper = _ref => { -@@ -21,7 +22,8 @@ const AppWrapper = _ref => { - plugin, - onPluginError, - clearPluginError, -- direction: configDirection -+ direction: configDirection, -+ reportPWAUpdateStatus - } = _ref; - const { - loading: localeLoading, -@@ -38,7 +40,9 @@ const AppWrapper = _ref => { - className: `jsx-${_AppWrapperStyle.styles.__hash}` + " " + "app-shell-adapter" - }, /*#__PURE__*/_react.default.createElement(_style.default, { - id: _AppWrapperStyle.styles.__hash -- }, _AppWrapperStyle.styles), /*#__PURE__*/_react.default.createElement("div", { -+ }, _AppWrapperStyle.styles), /*#__PURE__*/_react.default.createElement(_PluginPWAUpdateManager.PluginPWAUpdateManager, { -+ reportPWAUpdateStatus: reportPWAUpdateStatus -+ }), /*#__PURE__*/_react.default.createElement("div", { - className: `jsx-${_AppWrapperStyle.styles.__hash}` + " " + "app-shell-app" - }, /*#__PURE__*/_react.default.createElement(_ErrorBoundary.ErrorBoundary, { - plugin: true, -@@ -70,5 +74,6 @@ AppWrapper.propTypes = { - clearPluginError: _propTypes.default.func, - direction: _propTypes.default.oneOf(['ltr', 'rtl', 'auto']), - plugin: _propTypes.default.bool, -+ reportPWAUpdateStatus: _propTypes.default.func, - onPluginError: _propTypes.default.func - }; -\ No newline at end of file -diff --git a/node_modules/@dhis2/app-adapter/build/cjs/components/ConnectedHeaderBar.js b/node_modules/@dhis2/app-adapter/build/cjs/components/ConnectedHeaderBar.js -index 19da430..ff8af53 100644 ---- a/node_modules/@dhis2/app-adapter/build/cjs/components/ConnectedHeaderBar.js -+++ b/node_modules/@dhis2/app-adapter/build/cjs/components/ConnectedHeaderBar.js -@@ -5,9 +5,9 @@ Object.defineProperty(exports, "__esModule", { - }); - exports.ConnectedHeaderBar = ConnectedHeaderBar; - var _appRuntime = require("@dhis2/app-runtime"); -+var _pwa = require("@dhis2/pwa"); - var _ui = require("@dhis2/ui"); - var _react = _interopRequireDefault(require("react")); --var _usePWAUpdateState = require("../utils/usePWAUpdateState"); - var _ConfirmUpdateModal = require("./ConfirmUpdateModal"); - function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } - /** -@@ -29,7 +29,7 @@ function ConnectedHeaderBar() { - clientsCount, - onConfirmUpdate, - onCancelUpdate -- } = (0, _usePWAUpdateState.usePWAUpdateState)(); -+ } = (0, _pwa.usePWAUpdateState)(); - return /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, /*#__PURE__*/_react.default.createElement(_ui.HeaderBar, { - appName: appName, - updateAvailable: updateAvailable, -diff --git a/node_modules/@dhis2/app-adapter/build/cjs/components/OfflineInterfaceContext.js b/node_modules/@dhis2/app-adapter/build/cjs/components/OfflineInterfaceContext.js -deleted file mode 100644 -index 39751fa..0000000 ---- a/node_modules/@dhis2/app-adapter/build/cjs/components/OfflineInterfaceContext.js -+++ /dev/null -@@ -1,28 +0,0 @@ --"use strict"; -- --Object.defineProperty(exports, "__esModule", { -- value: true --}); --exports.useOfflineInterface = exports.OfflineInterfaceProvider = void 0; --var _pwa = require("@dhis2/pwa"); --var _propTypes = _interopRequireDefault(require("prop-types")); --var _react = _interopRequireWildcard(require("react")); --function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); } --function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; } --function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } --const theOfflineInterface = new _pwa.OfflineInterface(); --const OfflineInterfaceContext = /*#__PURE__*/(0, _react.createContext)(theOfflineInterface); --const OfflineInterfaceProvider = _ref => { -- let { -- children -- } = _ref; -- return /*#__PURE__*/_react.default.createElement(OfflineInterfaceContext.Provider, { -- value: theOfflineInterface -- }, children); --}; --exports.OfflineInterfaceProvider = OfflineInterfaceProvider; --OfflineInterfaceProvider.propTypes = { -- children: _propTypes.default.node --}; --const useOfflineInterface = () => (0, _react.useContext)(OfflineInterfaceContext); --exports.useOfflineInterface = useOfflineInterface; -\ No newline at end of file -diff --git a/node_modules/@dhis2/app-adapter/build/cjs/components/PWALoadingBoundary.js b/node_modules/@dhis2/app-adapter/build/cjs/components/PWALoadingBoundary.js -index 4837c52..720df23 100644 ---- a/node_modules/@dhis2/app-adapter/build/cjs/components/PWALoadingBoundary.js -+++ b/node_modules/@dhis2/app-adapter/build/cjs/components/PWALoadingBoundary.js -@@ -7,14 +7,13 @@ exports.PWALoadingBoundary = void 0; - var _pwa = require("@dhis2/pwa"); - var _propTypes = _interopRequireDefault(require("prop-types")); - var _react = require("react"); --var _OfflineInterfaceContext = require("./OfflineInterfaceContext"); - function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } - const PWALoadingBoundary = _ref => { - let { - children - } = _ref; - const [pwaReady, setPWAReady] = (0, _react.useState)(false); -- const offlineInterface = (0, _OfflineInterfaceContext.useOfflineInterface)(); -+ const offlineInterface = (0, _pwa.useOfflineInterface)(); - (0, _react.useEffect)(() => { - const checkRegistration = async () => { - const registrationState = await offlineInterface.getRegistrationState(); -diff --git a/node_modules/@dhis2/app-adapter/build/cjs/components/PluginPWAUpdateManager.js b/node_modules/@dhis2/app-adapter/build/cjs/components/PluginPWAUpdateManager.js -new file mode 100644 -index 0000000..69178fb ---- /dev/null -+++ b/node_modules/@dhis2/app-adapter/build/cjs/components/PluginPWAUpdateManager.js -@@ -0,0 +1,55 @@ -+"use strict"; -+ -+Object.defineProperty(exports, "__esModule", { -+ value: true -+}); -+exports.PluginPWAUpdateManager = PluginPWAUpdateManager; -+var _pwa = require("@dhis2/pwa"); -+var _propTypes = _interopRequireDefault(require("prop-types")); -+var _react = _interopRequireWildcard(require("react")); -+var _ConfirmUpdateModal = require("./ConfirmUpdateModal"); -+function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); } -+function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; } -+function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } -+/** -+ * Analogous to the ConnectedHeaderbar, for use in plugins since they don't -+ * use a header bar. See the ConnectedHeaderBar for more -+ */ -+ -+/** -+ * Check for SW updates or a first activation, sending a message to the host -+ * app if an update is ready. When an update is applied, if there are -+ * multiple tabs of this app open, there's anadditional warning step because all -+ * clients of the service worker will reload when there's an update, which may -+ * cause data loss. -+ */ -+ -+function PluginPWAUpdateManager(_ref) { -+ let { -+ reportPWAUpdateStatus -+ } = _ref; -+ const { -+ updateAvailable, -+ confirmReload, -+ confirmationRequired, -+ clientsCount, -+ onConfirmUpdate, -+ onCancelUpdate -+ } = (0, _pwa.usePWAUpdateState)(); -+ (0, _react.useEffect)(() => { -+ if (reportPWAUpdateStatus) { -+ reportPWAUpdateStatus({ -+ updateAvailable, -+ onApplyUpdate: updateAvailable ? confirmReload : null -+ }); -+ } -+ }, [updateAvailable, confirmReload, reportPWAUpdateStatus]); -+ return confirmationRequired ? /*#__PURE__*/_react.default.createElement(_ConfirmUpdateModal.ConfirmUpdateModal, { -+ clientsCount: clientsCount, -+ onConfirm: onConfirmUpdate, -+ onCancel: onCancelUpdate -+ }) : null; -+} -+PluginPWAUpdateManager.propTypes = { -+ reportPWAUpdateStatus: _propTypes.default.func -+}; -\ No newline at end of file -diff --git a/node_modules/@dhis2/app-adapter/build/cjs/components/ServerVersionProvider.js b/node_modules/@dhis2/app-adapter/build/cjs/components/ServerVersionProvider.js -index 1003086..6678085 100644 ---- a/node_modules/@dhis2/app-adapter/build/cjs/components/ServerVersionProvider.js -+++ b/node_modules/@dhis2/app-adapter/build/cjs/components/ServerVersionProvider.js -@@ -12,7 +12,6 @@ var _api = require("../utils/api.js"); - var _parseVersion = require("../utils/parseVersion.js"); - var _LoadingMask = require("./LoadingMask.js"); - var _LoginModal = require("./LoginModal.js"); --var _OfflineInterfaceContext = require("./OfflineInterfaceContext.js"); - function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); } - function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; } - function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } -@@ -21,7 +20,7 @@ const ServerVersionProvider = _ref => { - appName, - appVersion, - url, -- // url from env vars -+ // url from env vars or index.html interpolation - apiVersion, - pwaEnabled, - plugin, -@@ -30,7 +29,7 @@ const ServerVersionProvider = _ref => { - loginApp, - children - } = _ref; -- const offlineInterface = (0, _OfflineInterfaceContext.useOfflineInterface)(); -+ const offlineInterface = (0, _pwa.useOfflineInterface)(); - const [systemInfoState, setSystemInfoState] = (0, _react.useState)({ - loading: true, - error: undefined, -diff --git a/node_modules/@dhis2/app-adapter/build/cjs/index.js b/node_modules/@dhis2/app-adapter/build/cjs/index.js -index 173de2b..55421ae 100644 ---- a/node_modules/@dhis2/app-adapter/build/cjs/index.js -+++ b/node_modules/@dhis2/app-adapter/build/cjs/index.js -@@ -10,7 +10,6 @@ var _react = _interopRequireDefault(require("react")); - var _AppWrapper = require("./components/AppWrapper.js"); - var _ErrorBoundary = require("./components/ErrorBoundary.js"); - var _LoginAppWrapper = require("./components/LoginAppWrapper.js"); --var _OfflineInterfaceContext = require("./components/OfflineInterfaceContext.js"); - var _PWALoadingBoundary = require("./components/PWALoadingBoundary.js"); - var _ServerVersionProvider = require("./components/ServerVersionProvider.js"); - function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } -@@ -27,6 +26,7 @@ const AppAdapter = _ref => { - showAlertsInPlugin, - onPluginError, - clearPluginError, -+ reportPWAUpdateStatus, - loginApp, - children - } = _ref; -@@ -53,7 +53,7 @@ const AppAdapter = _ref => { - plugin: plugin, - fullscreen: true, - onRetry: _pwa.checkForSWUpdateAndReload -- }, /*#__PURE__*/_react.default.createElement(_OfflineInterfaceContext.OfflineInterfaceProvider, null, /*#__PURE__*/_react.default.createElement(_PWALoadingBoundary.PWALoadingBoundary, null, /*#__PURE__*/_react.default.createElement(_ServerVersionProvider.ServerVersionProvider, { -+ }, /*#__PURE__*/_react.default.createElement(_pwa.OfflineInterfaceProvider, null, /*#__PURE__*/_react.default.createElement(_PWALoadingBoundary.PWALoadingBoundary, null, /*#__PURE__*/_react.default.createElement(_ServerVersionProvider.ServerVersionProvider, { - appName: appName, - appVersion: appVersion, - url: url, -@@ -66,7 +66,8 @@ const AppAdapter = _ref => { - plugin: plugin, - onPluginError: onPluginError, - clearPluginError: clearPluginError, -- direction: direction -+ direction: direction, -+ reportPWAUpdateStatus: reportPWAUpdateStatus - }, children))))); - }; - AppAdapter.propTypes = { -@@ -80,6 +81,7 @@ AppAdapter.propTypes = { - parentAlertsAdd: _propTypes.default.func, - plugin: _propTypes.default.bool, - pwaEnabled: _propTypes.default.bool, -+ reportPWAUpdateStatus: _propTypes.default.func, - showAlertsInPlugin: _propTypes.default.bool, - url: _propTypes.default.string, - onPluginError: _propTypes.default.func -diff --git a/node_modules/@dhis2/app-adapter/build/cjs/utils/usePWAUpdateState.js b/node_modules/@dhis2/app-adapter/build/cjs/utils/usePWAUpdateState.js -deleted file mode 100644 -index 3b757f9..0000000 ---- a/node_modules/@dhis2/app-adapter/build/cjs/utils/usePWAUpdateState.js -+++ /dev/null -@@ -1,56 +0,0 @@ --"use strict"; -- --Object.defineProperty(exports, "__esModule", { -- value: true --}); --exports.usePWAUpdateState = void 0; --var _react = require("react"); --var _OfflineInterfaceContext = require("../components/OfflineInterfaceContext"); --const usePWAUpdateState = () => { -- const offlineInterface = (0, _OfflineInterfaceContext.useOfflineInterface)(); -- const [updateAvailable, setUpdateAvailable] = (0, _react.useState)(false); -- const [clientsCount, setClientsCount] = (0, _react.useState)(null); -- const onConfirmUpdate = () => { -- offlineInterface.useNewSW(); -- }; -- const onCancelUpdate = () => { -- setClientsCount(null); -- }; -- const confirmReload = () => { -- offlineInterface.getClientsInfo().then(_ref => { -- let { -- clientsCount -- } = _ref; -- if (clientsCount === 1) { -- // Just one client; go ahead and reload -- onConfirmUpdate(); -- } else { -- // Multiple clients; warn about data loss before reloading -- setClientsCount(clientsCount); -- } -- }).catch(reason => { -- // Didn't get clients info -- console.warn(reason); -- -- // Go ahead with confirmation modal with `0` as clientsCount -- setClientsCount(0); -- }); -- }; -- (0, _react.useEffect)(() => { -- offlineInterface.checkForNewSW({ -- onNewSW: () => { -- setUpdateAvailable(true); -- } -- }); -- }, [offlineInterface]); -- const confirmationRequired = clientsCount !== null; -- return { -- updateAvailable, -- confirmReload, -- confirmationRequired, -- clientsCount, -- onConfirmUpdate, -- onCancelUpdate -- }; --}; --exports.usePWAUpdateState = usePWAUpdateState; -\ No newline at end of file -diff --git a/node_modules/@dhis2/app-adapter/build/es/components/AppWrapper.js b/node_modules/@dhis2/app-adapter/build/es/components/AppWrapper.js -index 292218e..e4f34e5 100644 ---- a/node_modules/@dhis2/app-adapter/build/es/components/AppWrapper.js -+++ b/node_modules/@dhis2/app-adapter/build/es/components/AppWrapper.js -@@ -7,6 +7,7 @@ import { Alerts } from './Alerts.js'; - import { ConnectedHeaderBar } from './ConnectedHeaderBar.js'; - import { ErrorBoundary } from './ErrorBoundary.js'; - import { LoadingMask } from './LoadingMask.js'; -+import { PluginPWAUpdateManager } from './PluginPWAUpdateManager.js'; - import { styles } from './styles/AppWrapper.style.js'; - const AppWrapper = _ref => { - let { -@@ -14,7 +15,8 @@ const AppWrapper = _ref => { - plugin, - onPluginError, - clearPluginError, -- direction: configDirection -+ direction: configDirection, -+ reportPWAUpdateStatus - } = _ref; - const { - loading: localeLoading, -@@ -31,7 +33,9 @@ const AppWrapper = _ref => { - className: `jsx-${styles.__hash}` + " " + "app-shell-adapter" - }, /*#__PURE__*/React.createElement(_JSXStyle, { - id: styles.__hash -- }, styles), /*#__PURE__*/React.createElement("div", { -+ }, styles), /*#__PURE__*/React.createElement(PluginPWAUpdateManager, { -+ reportPWAUpdateStatus: reportPWAUpdateStatus -+ }), /*#__PURE__*/React.createElement("div", { - className: `jsx-${styles.__hash}` + " " + "app-shell-app" - }, /*#__PURE__*/React.createElement(ErrorBoundary, { - plugin: true, -@@ -62,6 +66,7 @@ AppWrapper.propTypes = { - clearPluginError: PropTypes.func, - direction: PropTypes.oneOf(['ltr', 'rtl', 'auto']), - plugin: PropTypes.bool, -+ reportPWAUpdateStatus: PropTypes.func, - onPluginError: PropTypes.func - }; - export { AppWrapper }; -\ No newline at end of file -diff --git a/node_modules/@dhis2/app-adapter/build/es/components/ConnectedHeaderBar.js b/node_modules/@dhis2/app-adapter/build/es/components/ConnectedHeaderBar.js -index 950f05c..809a074 100644 ---- a/node_modules/@dhis2/app-adapter/build/es/components/ConnectedHeaderBar.js -+++ b/node_modules/@dhis2/app-adapter/build/es/components/ConnectedHeaderBar.js -@@ -1,7 +1,7 @@ - import { useConfig } from '@dhis2/app-runtime'; -+import { usePWAUpdateState } from '@dhis2/pwa'; - import { HeaderBar } from '@dhis2/ui'; - import React from 'react'; --import { usePWAUpdateState } from '../utils/usePWAUpdateState'; - import { ConfirmUpdateModal } from './ConfirmUpdateModal'; - - /** -diff --git a/node_modules/@dhis2/app-adapter/build/es/components/OfflineInterfaceContext.js b/node_modules/@dhis2/app-adapter/build/es/components/OfflineInterfaceContext.js -deleted file mode 100644 -index d2c52f3..0000000 ---- a/node_modules/@dhis2/app-adapter/build/es/components/OfflineInterfaceContext.js -+++ /dev/null -@@ -1,17 +0,0 @@ --import { OfflineInterface } from '@dhis2/pwa'; --import PropTypes from 'prop-types'; --import React, { createContext, useContext } from 'react'; --const theOfflineInterface = new OfflineInterface(); --const OfflineInterfaceContext = /*#__PURE__*/createContext(theOfflineInterface); --export const OfflineInterfaceProvider = _ref => { -- let { -- children -- } = _ref; -- return /*#__PURE__*/React.createElement(OfflineInterfaceContext.Provider, { -- value: theOfflineInterface -- }, children); --}; --OfflineInterfaceProvider.propTypes = { -- children: PropTypes.node --}; --export const useOfflineInterface = () => useContext(OfflineInterfaceContext); -\ No newline at end of file -diff --git a/node_modules/@dhis2/app-adapter/build/es/components/PWALoadingBoundary.js b/node_modules/@dhis2/app-adapter/build/es/components/PWALoadingBoundary.js -index d0e225c..db3f72e 100644 ---- a/node_modules/@dhis2/app-adapter/build/es/components/PWALoadingBoundary.js -+++ b/node_modules/@dhis2/app-adapter/build/es/components/PWALoadingBoundary.js -@@ -1,7 +1,6 @@ --import { REGISTRATION_STATE_WAITING, REGISTRATION_STATE_FIRST_ACTIVATION } from '@dhis2/pwa'; -+import { useOfflineInterface, REGISTRATION_STATE_WAITING, REGISTRATION_STATE_FIRST_ACTIVATION } from '@dhis2/pwa'; - import PropTypes from 'prop-types'; - import { useEffect, useState } from 'react'; --import { useOfflineInterface } from './OfflineInterfaceContext'; - export const PWALoadingBoundary = _ref => { - let { - children -diff --git a/node_modules/@dhis2/app-adapter/build/es/components/PluginPWAUpdateManager.js b/node_modules/@dhis2/app-adapter/build/es/components/PluginPWAUpdateManager.js -new file mode 100644 -index 0000000..cb9bdd7 ---- /dev/null -+++ b/node_modules/@dhis2/app-adapter/build/es/components/PluginPWAUpdateManager.js -@@ -0,0 +1,47 @@ -+import { usePWAUpdateState } from '@dhis2/pwa'; -+import PropTypes from 'prop-types'; -+import React, { useEffect } from 'react'; -+import { ConfirmUpdateModal } from './ConfirmUpdateModal'; -+ -+/** -+ * Analogous to the ConnectedHeaderbar, for use in plugins since they don't -+ * use a header bar. See the ConnectedHeaderBar for more -+ */ -+ -+/** -+ * Check for SW updates or a first activation, sending a message to the host -+ * app if an update is ready. When an update is applied, if there are -+ * multiple tabs of this app open, there's anadditional warning step because all -+ * clients of the service worker will reload when there's an update, which may -+ * cause data loss. -+ */ -+ -+export function PluginPWAUpdateManager(_ref) { -+ let { -+ reportPWAUpdateStatus -+ } = _ref; -+ const { -+ updateAvailable, -+ confirmReload, -+ confirmationRequired, -+ clientsCount, -+ onConfirmUpdate, -+ onCancelUpdate -+ } = usePWAUpdateState(); -+ useEffect(() => { -+ if (reportPWAUpdateStatus) { -+ reportPWAUpdateStatus({ -+ updateAvailable, -+ onApplyUpdate: updateAvailable ? confirmReload : null -+ }); -+ } -+ }, [updateAvailable, confirmReload, reportPWAUpdateStatus]); -+ return confirmationRequired ? /*#__PURE__*/React.createElement(ConfirmUpdateModal, { -+ clientsCount: clientsCount, -+ onConfirm: onConfirmUpdate, -+ onCancel: onCancelUpdate -+ }) : null; -+} -+PluginPWAUpdateManager.propTypes = { -+ reportPWAUpdateStatus: PropTypes.func -+}; -\ No newline at end of file -diff --git a/node_modules/@dhis2/app-adapter/build/es/components/ServerVersionProvider.js b/node_modules/@dhis2/app-adapter/build/es/components/ServerVersionProvider.js -index 6d0e69b..e437669 100644 ---- a/node_modules/@dhis2/app-adapter/build/es/components/ServerVersionProvider.js -+++ b/node_modules/@dhis2/app-adapter/build/es/components/ServerVersionProvider.js -@@ -1,18 +1,17 @@ - import { Provider } from '@dhis2/app-runtime'; --import { getBaseUrlByAppName, setBaseUrlByAppName } from '@dhis2/pwa'; -+import { getBaseUrlByAppName, setBaseUrlByAppName, useOfflineInterface } from '@dhis2/pwa'; - import PropTypes from 'prop-types'; - import React, { useEffect, useState } from 'react'; - import { get } from '../utils/api.js'; - import { parseDHIS2ServerVersion, parseVersion } from '../utils/parseVersion.js'; - import { LoadingMask } from './LoadingMask.js'; - import { LoginModal } from './LoginModal.js'; --import { useOfflineInterface } from './OfflineInterfaceContext.js'; - export const ServerVersionProvider = _ref => { - let { - appName, - appVersion, - url, -- // url from env vars -+ // url from env vars or index.html interpolation - apiVersion, - pwaEnabled, - plugin, -diff --git a/node_modules/@dhis2/app-adapter/build/es/index.js b/node_modules/@dhis2/app-adapter/build/es/index.js -index f440aee..a3f9d52 100644 ---- a/node_modules/@dhis2/app-adapter/build/es/index.js -+++ b/node_modules/@dhis2/app-adapter/build/es/index.js -@@ -1,10 +1,9 @@ --import { checkForSWUpdateAndReload } from '@dhis2/pwa'; -+import { checkForSWUpdateAndReload, OfflineInterfaceProvider } from '@dhis2/pwa'; - import PropTypes from 'prop-types'; - import React from 'react'; - import { AppWrapper } from './components/AppWrapper.js'; - import { ErrorBoundary } from './components/ErrorBoundary.js'; - import { LoginAppWrapper } from './components/LoginAppWrapper.js'; --import { OfflineInterfaceProvider } from './components/OfflineInterfaceContext.js'; - import { PWALoadingBoundary } from './components/PWALoadingBoundary.js'; - import { ServerVersionProvider } from './components/ServerVersionProvider.js'; - const AppAdapter = _ref => { -@@ -20,6 +19,7 @@ const AppAdapter = _ref => { - showAlertsInPlugin, - onPluginError, - clearPluginError, -+ reportPWAUpdateStatus, - loginApp, - children - } = _ref; -@@ -59,7 +59,8 @@ const AppAdapter = _ref => { - plugin: plugin, - onPluginError: onPluginError, - clearPluginError: clearPluginError, -- direction: direction -+ direction: direction, -+ reportPWAUpdateStatus: reportPWAUpdateStatus - }, children))))); - }; - AppAdapter.propTypes = { -@@ -73,6 +74,7 @@ AppAdapter.propTypes = { - parentAlertsAdd: PropTypes.func, - plugin: PropTypes.bool, - pwaEnabled: PropTypes.bool, -+ reportPWAUpdateStatus: PropTypes.func, - showAlertsInPlugin: PropTypes.bool, - url: PropTypes.string, - onPluginError: PropTypes.func -diff --git a/node_modules/@dhis2/app-adapter/build/es/utils/usePWAUpdateState.js b/node_modules/@dhis2/app-adapter/build/es/utils/usePWAUpdateState.js -deleted file mode 100644 -index 4c5c55a..0000000 ---- a/node_modules/@dhis2/app-adapter/build/es/utils/usePWAUpdateState.js -+++ /dev/null -@@ -1,49 +0,0 @@ --import { useEffect, useState } from 'react'; --import { useOfflineInterface } from '../components/OfflineInterfaceContext'; --export const usePWAUpdateState = () => { -- const offlineInterface = useOfflineInterface(); -- const [updateAvailable, setUpdateAvailable] = useState(false); -- const [clientsCount, setClientsCount] = useState(null); -- const onConfirmUpdate = () => { -- offlineInterface.useNewSW(); -- }; -- const onCancelUpdate = () => { -- setClientsCount(null); -- }; -- const confirmReload = () => { -- offlineInterface.getClientsInfo().then(_ref => { -- let { -- clientsCount -- } = _ref; -- if (clientsCount === 1) { -- // Just one client; go ahead and reload -- onConfirmUpdate(); -- } else { -- // Multiple clients; warn about data loss before reloading -- setClientsCount(clientsCount); -- } -- }).catch(reason => { -- // Didn't get clients info -- console.warn(reason); -- -- // Go ahead with confirmation modal with `0` as clientsCount -- setClientsCount(0); -- }); -- }; -- useEffect(() => { -- offlineInterface.checkForNewSW({ -- onNewSW: () => { -- setUpdateAvailable(true); -- } -- }); -- }, [offlineInterface]); -- const confirmationRequired = clientsCount !== null; -- return { -- updateAvailable, -- confirmReload, -- confirmationRequired, -- clientsCount, -- onConfirmUpdate, -- onCancelUpdate -- }; --}; -\ No newline at end of file diff --git a/patches/@dhis2+app-service-plugin+3.13.2.patch b/patches/@dhis2+app-service-plugin+3.13.2.patch deleted file mode 100644 index dbeadd3..0000000 --- a/patches/@dhis2+app-service-plugin+3.13.2.patch +++ /dev/null @@ -1,97 +0,0 @@ -diff --git a/node_modules/@dhis2/app-service-plugin/build/cjs/Plugin.js b/node_modules/@dhis2/app-service-plugin/build/cjs/Plugin.js -index 49f6732..21a8e23 100644 ---- a/node_modules/@dhis2/app-service-plugin/build/cjs/Plugin.js -+++ b/node_modules/@dhis2/app-service-plugin/build/cjs/Plugin.js -@@ -36,6 +36,7 @@ const Plugin = _ref3 => { - let { - pluginSource, - pluginShortName, -+ onLoad, - height, - width, - className, -@@ -121,6 +122,7 @@ const Plugin = _ref3 => { - { - window: iframeRef.current.contentWindow - }, () => { -+ console.log('sending updated props'); - communicationReceivedRef.current = true; - return iframeProps; - }); -@@ -129,6 +131,7 @@ const Plugin = _ref3 => { - - // If iframe has sent initial request, send new props - if (communicationReceivedRef.current && iframeRef.current.contentWindow && !inErrorState) { -+ console.log('sending updated props'); - _postRobot.default.send(iframeRef.current.contentWindow, 'updated', iframeProps).catch(err => { - // log postRobot errors, but do not bubble them up - console.error(err); -@@ -159,7 +162,8 @@ const Plugin = _ref3 => { - height: height !== null && height !== void 0 ? height : resizedHeight, - style: { - border: 'none' -- } -+ }, -+ onLoad: onLoad - }); - }; - exports.Plugin = Plugin; -\ No newline at end of file -diff --git a/node_modules/@dhis2/app-service-plugin/build/es/Plugin.js b/node_modules/@dhis2/app-service-plugin/build/es/Plugin.js -index 7adc7cf..7f10539 100644 ---- a/node_modules/@dhis2/app-service-plugin/build/es/Plugin.js -+++ b/node_modules/@dhis2/app-service-plugin/build/es/Plugin.js -@@ -27,6 +27,7 @@ export const Plugin = _ref3 => { - let { - pluginSource, - pluginShortName, -+ onLoad, - height, - width, - className, -@@ -112,6 +113,7 @@ export const Plugin = _ref3 => { - { - window: iframeRef.current.contentWindow - }, () => { -+ console.log('sending updated props'); - communicationReceivedRef.current = true; - return iframeProps; - }); -@@ -120,6 +122,7 @@ export const Plugin = _ref3 => { - - // If iframe has sent initial request, send new props - if (communicationReceivedRef.current && iframeRef.current.contentWindow && !inErrorState) { -+ console.log('sending updated props'); - postRobot.send(iframeRef.current.contentWindow, 'updated', iframeProps).catch(err => { - // log postRobot errors, but do not bubble them up - console.error(err); -@@ -150,6 +153,7 @@ export const Plugin = _ref3 => { - height: height !== null && height !== void 0 ? height : resizedHeight, - style: { - border: 'none' -- } -+ }, -+ onLoad: onLoad - }); - }; -\ No newline at end of file -diff --git a/node_modules/@dhis2/app-service-plugin/build/types/Plugin.d.ts b/node_modules/@dhis2/app-service-plugin/build/types/Plugin.d.ts -index 23b9a60..75cbab3 100644 ---- a/node_modules/@dhis2/app-service-plugin/build/types/Plugin.d.ts -+++ b/node_modules/@dhis2/app-service-plugin/build/types/Plugin.d.ts -@@ -1,4 +1,4 @@ --/// -+import { ReactEventHandler } from 'react'; - type PluginProps = { - /** URL to provide to iframe `src` */ - pluginSource?: string; -@@ -41,6 +41,8 @@ type PluginProps = { - clientWidth?: string | number; - /** Props that will be sent to the plugin */ - propsToPassNonMemoized?: any; -+ /** Event callback that will be called during the iframe's Load event */ -+ onLoad?: ReactEventHandler; - }; --export declare const Plugin: ({ pluginSource, pluginShortName, height, width, className, clientWidth, ...propsToPassNonMemoized }: PluginProps) => JSX.Element; -+export declare const Plugin: ({ pluginSource, pluginShortName, onLoad, height, width, className, clientWidth, ...propsToPassNonMemoized }: PluginProps) => JSX.Element; - export {}; diff --git a/patches/@dhis2+pwa+12.3.0.patch b/patches/@dhis2+pwa+12.3.0.patch index 96836fa..88bd3de 100644 --- a/patches/@dhis2+pwa+12.3.0.patch +++ b/patches/@dhis2+pwa+12.3.0.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@dhis2/pwa/build/cjs/index.js b/node_modules/@dhis2/pwa/build/cjs/index.js -index 571bd0e..1ca95ba 100644 +index 571bd0e..163b12e 100644 --- a/node_modules/@dhis2/pwa/build/cjs/index.js +++ b/node_modules/@dhis2/pwa/build/cjs/index.js -@@ -9,6 +9,12 @@ Object.defineProperty(exports, "OfflineInterface", { +@@ -9,6 +9,18 @@ Object.defineProperty(exports, "OfflineInterface", { return _offlineInterface.OfflineInterface; } }); @@ -11,11 +11,17 @@ index 571bd0e..1ca95ba 100644 + get: function () { + return _OfflineInterfaceContext.OfflineInterfaceProvider; + } ++}); ++Object.defineProperty(exports, "PWAUpdateOfflineInterface", { ++ enumerable: true, ++ get: function () { ++ return _pwaUpdateOfflineInterface.PWAUpdateOfflineInterface; ++ } +}); Object.defineProperty(exports, "REGISTRATION_STATE_ACTIVE", { enumerable: true, get: function () { -@@ -69,6 +75,18 @@ Object.defineProperty(exports, "setUpServiceWorker", { +@@ -69,7 +81,22 @@ Object.defineProperty(exports, "setUpServiceWorker", { return _setUpServiceWorker.setUpServiceWorker; } }); @@ -33,7 +39,234 @@ index 571bd0e..1ca95ba 100644 +}); var _setUpServiceWorker = require("./service-worker/set-up-service-worker.js"); var _offlineInterface = require("./offline-interface/offline-interface.js"); ++var _pwaUpdateOfflineInterface = require("./offline-interface/pwa-update-offline-interface.js"); var _registration = require("./lib/registration.js"); + var _baseUrlDb = require("./lib/base-url-db.js"); ++var _OfflineInterfaceContext = require("./react/OfflineInterfaceContext.js"); ++var _usePWAUpdateState = require("./react/usePWAUpdateState.js"); +diff --git a/node_modules/@dhis2/pwa/build/cjs/lib/registration.js b/node_modules/@dhis2/pwa/build/cjs/lib/registration.js +index 1900021..7e3574b 100644 +--- a/node_modules/@dhis2/pwa/build/cjs/lib/registration.js ++++ b/node_modules/@dhis2/pwa/build/cjs/lib/registration.js +@@ -36,16 +36,32 @@ async function getRegistrationState() { + return REGISTRATION_STATE_ACTIVE; + } + } ++ ++/** ++ * Can receive a specific SW instance to check for updates on, e.g. for a ++ * plugin window. Defaults to this window's navigator.serviceWorker. ++ * onUpdate is called with `{ registration }` ++ */ + async function checkForUpdates(_ref) { + let { +- onUpdate ++ onUpdate, ++ targetServiceWorker + } = _ref; +- if (!('serviceWorker' in navigator)) { ++ if (!('serviceWorker' in navigator) && !targetServiceWorker) { + return; + } +- const registration = await navigator.serviceWorker.getRegistration(); ++ const serviceWorker = targetServiceWorker || navigator.serviceWorker; ++ let registration = await serviceWorker.getRegistration(); + if (registration === undefined) { +- return; ++ // This could have raced before the call to `serviceWorker.register()`; ++ // wait and try again. Testing with a 20x CPU throttling in Chrome, ++ // 500 ms works on an M3 max macbook pro ++ await new Promise(r => setTimeout(r, 500)); ++ registration = await serviceWorker.getRegistration(); ++ if (registration === undefined) { ++ // Still didn't find it; probably not a PWA app. ++ return; ++ } + } + function handleWaitingSW() { + console.log('New content is available and will be used when all tabs for this page are closed.'); +@@ -76,21 +92,21 @@ async function checkForUpdates(_ref) { + // callback doesn't get called in that case. Handle that here: + if (registration.waiting) { + handleWaitingSW(); +- } else if (registration.active && navigator.serviceWorker.controller === null) { ++ } else if (registration.active && serviceWorker.controller === null) { + handleFirstSWActivation(); + } + function handleInstallingWorker() { + const installingWorker = registration.installing; + if (installingWorker) { +- installingWorker.onstatechange = () => { +- if (installingWorker.state === 'installed' && navigator.serviceWorker.controller) { ++ installingWorker.addEventListener('statechange', () => { ++ if (installingWorker.state === 'installed' && serviceWorker.controller) { + // SW is waiting to become active + handleWaitingSW(); +- } else if (installingWorker.state === 'activated' && !navigator.serviceWorker.controller) { ++ } else if (installingWorker.state === 'activated' && !serviceWorker.controller) { + // First SW is installed and active + handleFirstSWActivation(); + } +- }; ++ }); + } + } + +@@ -100,7 +116,7 @@ async function checkForUpdates(_ref) { + } + + // If a new service worker will be installed: +- registration.onupdatefound = handleInstallingWorker; ++ registration.addEventListener('updatefound', handleInstallingWorker); + } + + /** +diff --git a/node_modules/@dhis2/pwa/build/cjs/offline-interface/pwa-update-offline-interface.js b/node_modules/@dhis2/pwa/build/cjs/offline-interface/pwa-update-offline-interface.js +new file mode 100644 +index 0000000..334c888 +--- /dev/null ++++ b/node_modules/@dhis2/pwa/build/cjs/offline-interface/pwa-update-offline-interface.js +@@ -0,0 +1,140 @@ ++"use strict"; ++ ++Object.defineProperty(exports, "__esModule", { ++ value: true ++}); ++exports.PWAUpdateOfflineInterface = void 0; ++var _events = _interopRequireDefault(require("events")); ++var _constants = require("../lib/constants.js"); ++var _registration = require("../lib/registration.js"); ++function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } ++// todo: this could be used as a base for the other, larger offline interface ++ ++/** ++ * This and the following 'test' functions test for PWA features and log errors ++ * if there's an issue so they can be reused in the Offline Interface methods. ++ * ++ * Known situations when navigator.serviceWorker is not available: ++ * 1. Private browsing in firefox ++ * 2. Insecure contexts (e.g. http that's not local host) ++ */ ++function testSWAvailable(_ref) { ++ let { ++ targetWindow = window ++ } = _ref; ++ if ('serviceWorker' in targetWindow.navigator) { ++ return true; ++ } ++ const msg = (!targetWindow.isSecureContext ? 'This window is not a secure context -- see https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts.' : '`serviceWorker` is not available on `navigator`.') + ' PWA features will not work.'; ++ console.error(new Error(msg)); ++ return false; ++} ++ ++/** This exposes an interface to check a */ ++class PWAUpdateOfflineInterface { ++ constructor(_ref2) { ++ let { ++ targetWindow = window ++ } = _ref2; ++ if (!testSWAvailable({ ++ targetWindow ++ })) { ++ return; ++ } ++ this.targetWindow = targetWindow; ++ this.serviceWorker = targetWindow.navigator.serviceWorker; ++ ++ // This event emitter helps coordinate with service worker messages ++ this.offlineEvents = new _events.default(); ++ // Receives messages from service worker and forwards to event emitter ++ const handleSWMessage = event => { ++ if (!event.data) { ++ return; ++ } ++ const { ++ type, ++ payload ++ } = event.data; ++ this.offlineEvents.emit(type, payload); ++ }; ++ this.serviceWorker.addEventListener('message', handleSWMessage); ++ } ++ ++ /** Basically `checkForUpdates` from registration.js exposed here */ ++ checkForNewSW(_ref3) { ++ let { ++ onNewSW ++ } = _ref3; ++ // Check for SW updates (or first activation) ++ (0, _registration.checkForUpdates)({ ++ onUpdate: onNewSW, ++ targetServiceWorker: this.serviceWorker ++ }); ++ } ++ ++ /** ++ * Requests clients info from the active service worker. ++ * @returns {Promise} ++ */ ++ getClientsInfo() { ++ if (!testSWAvailable({ ++ targetWindow: this.targetWindow ++ })) { ++ return Promise.resolve({ ++ clientsCount: 0 ++ }); ++ } ++ return new Promise((resolve, reject) => { ++ this.serviceWorker.getRegistration().then(registration => { ++ const newestSW = (registration === null || registration === void 0 ? void 0 : registration.waiting) || (registration === null || registration === void 0 ? void 0 : registration.active); ++ if (!newestSW) { ++ resolve({ ++ clientsCount: 0 ++ }); ++ return; ++ } ++ ++ // Send request message to newest SW ++ newestSW.postMessage({ ++ type: _constants.swMsgs.getClientsInfo ++ }); ++ // Resolve with payload received from SW `clientsInfo` message ++ this.offlineEvents.once(_constants.swMsgs.clientsInfo, resolve); ++ // Clean up potentially unused listeners eventually ++ setTimeout(() => { ++ reject('Request for clients info timed out'); ++ this.offlineEvents.removeAllListeners(_constants.swMsgs.clientsInfo); ++ }, 2000); ++ }); ++ }); ++ } ++ ++ /** ++ * Makes a new SW either skip waiting if it's an update, ++ * or claim clients if it's the first SW activation ++ */ ++ useNewSW() { ++ if (!testSWAvailable({ ++ targetWindow: this.targetWindow ++ })) { ++ return Promise.resolve(); ++ } ++ return this.serviceWorker.getRegistration().then(registration => { ++ if (!registration) { ++ throw new Error('No service worker is registered'); ++ } ++ if (registration.waiting) { ++ // Update existing service worker ++ registration.waiting.postMessage({ ++ type: _constants.swMsgs.skipWaiting ++ }); ++ } else if (registration.active) { ++ // (First SW activation) Have SW take control of clients ++ registration.active.postMessage({ ++ type: _constants.swMsgs.claimClients ++ }); ++ } ++ }); ++ } ++} ++exports.PWAUpdateOfflineInterface = PWAUpdateOfflineInterface; diff --git a/node_modules/@dhis2/pwa/build/cjs/react/OfflineInterfaceContext.js b/node_modules/@dhis2/pwa/build/cjs/react/OfflineInterfaceContext.js new file mode 100644 index 0000000..2d465d7 @@ -77,7 +310,6 @@ index 0000000..2d465d7 +}; +const useOfflineInterface = () => (0, _react.useContext)(OfflineInterfaceContext); +exports.useOfflineInterface = useOfflineInterface; -\ No newline at end of file diff --git a/node_modules/@dhis2/pwa/build/cjs/react/usePWAUpdateState.js b/node_modules/@dhis2/pwa/build/cjs/react/usePWAUpdateState.js new file mode 100644 index 0000000..7dea937 @@ -140,16 +372,257 @@ index 0000000..7dea937 + }; +}; +exports.usePWAUpdateState = usePWAUpdateState; +diff --git a/node_modules/@dhis2/pwa/build/cjs/service-worker/set-up-service-worker.js b/node_modules/@dhis2/pwa/build/cjs/service-worker/set-up-service-worker.js +index 6fbacf8..948b93a 100644 +--- a/node_modules/@dhis2/pwa/build/cjs/service-worker/set-up-service-worker.js ++++ b/node_modules/@dhis2/pwa/build/cjs/service-worker/set-up-service-worker.js +@@ -56,6 +56,8 @@ function setUpServiceWorker() { + } = _ref; + return url.endsWith('index.html'); + }); ++ // Make sure that this request doesn't redirect to a global shell ++ indexHtmlManifestEntry.url += '?redirect=false'; + (0, _workboxPrecaching.precache)([indexHtmlManifestEntry]); + + // Custom strategy for handling app navigation, specifically to allow +@@ -87,7 +89,8 @@ function setUpServiceWorker() { + // Return true to signal that we want to use the handler. + return true; + }; +- const indexUrl = process.env.PUBLIC_URL + '/index.html'; ++ // Above, the index entry had the redirect param added: ++ const indexUrl = process.env.PUBLIC_URL + '/index.html?redirect=false'; + const navigationRouteHandler = _ref3 => { + let { + request diff --git a/node_modules/@dhis2/pwa/build/es/index.js b/node_modules/@dhis2/pwa/build/es/index.js -index 960e2b2..5bbcbff 100644 +index 960e2b2..bacd6a9 100644 --- a/node_modules/@dhis2/pwa/build/es/index.js +++ b/node_modules/@dhis2/pwa/build/es/index.js -@@ -2,3 +2,5 @@ export { setUpServiceWorker } from './service-worker/set-up-service-worker.js'; +@@ -1,4 +1,7 @@ + export { setUpServiceWorker } from './service-worker/set-up-service-worker.js'; export { OfflineInterface } from './offline-interface/offline-interface.js'; ++export { PWAUpdateOfflineInterface } from './offline-interface/pwa-update-offline-interface.js'; export { checkForUpdates, checkForSWUpdateAndReload, getRegistrationState, REGISTRATION_STATE_UNREGISTERED, REGISTRATION_STATE_WAITING, REGISTRATION_STATE_ACTIVE, REGISTRATION_STATE_FIRST_ACTIVATION } from './lib/registration.js'; export { getBaseUrlByAppName, setBaseUrlByAppName } from './lib/base-url-db.js'; +export { OfflineInterfaceProvider, useOfflineInterface } from './react/OfflineInterfaceContext.js'; -+export { usePWAUpdateState } from './react/usePWAUpdateState.js' ++export { usePWAUpdateState } from './react/usePWAUpdateState.js'; +diff --git a/node_modules/@dhis2/pwa/build/es/lib/registration.js b/node_modules/@dhis2/pwa/build/es/lib/registration.js +index dcbe2e9..a3d91e8 100644 +--- a/node_modules/@dhis2/pwa/build/es/lib/registration.js ++++ b/node_modules/@dhis2/pwa/build/es/lib/registration.js +@@ -25,16 +25,32 @@ export async function getRegistrationState() { + return REGISTRATION_STATE_ACTIVE; + } + } ++ ++/** ++ * Can receive a specific SW instance to check for updates on, e.g. for a ++ * plugin window. Defaults to this window's navigator.serviceWorker. ++ * onUpdate is called with `{ registration }` ++ */ + export async function checkForUpdates(_ref) { + let { +- onUpdate ++ onUpdate, ++ targetServiceWorker + } = _ref; +- if (!('serviceWorker' in navigator)) { ++ if (!('serviceWorker' in navigator) && !targetServiceWorker) { + return; + } +- const registration = await navigator.serviceWorker.getRegistration(); ++ const serviceWorker = targetServiceWorker || navigator.serviceWorker; ++ let registration = await serviceWorker.getRegistration(); + if (registration === undefined) { +- return; ++ // This could have raced before the call to `serviceWorker.register()`; ++ // wait and try again. Testing with a 20x CPU throttling in Chrome, ++ // 500 ms works on an M3 max macbook pro ++ await new Promise(r => setTimeout(r, 500)); ++ registration = await serviceWorker.getRegistration(); ++ if (registration === undefined) { ++ // Still didn't find it; probably not a PWA app. ++ return; ++ } + } + function handleWaitingSW() { + console.log('New content is available and will be used when all tabs for this page are closed.'); +@@ -65,21 +81,21 @@ export async function checkForUpdates(_ref) { + // callback doesn't get called in that case. Handle that here: + if (registration.waiting) { + handleWaitingSW(); +- } else if (registration.active && navigator.serviceWorker.controller === null) { ++ } else if (registration.active && serviceWorker.controller === null) { + handleFirstSWActivation(); + } + function handleInstallingWorker() { + const installingWorker = registration.installing; + if (installingWorker) { +- installingWorker.onstatechange = () => { +- if (installingWorker.state === 'installed' && navigator.serviceWorker.controller) { ++ installingWorker.addEventListener('statechange', () => { ++ if (installingWorker.state === 'installed' && serviceWorker.controller) { + // SW is waiting to become active + handleWaitingSW(); +- } else if (installingWorker.state === 'activated' && !navigator.serviceWorker.controller) { ++ } else if (installingWorker.state === 'activated' && !serviceWorker.controller) { + // First SW is installed and active + handleFirstSWActivation(); + } +- }; ++ }); + } + } + +@@ -89,7 +105,7 @@ export async function checkForUpdates(_ref) { + } + + // If a new service worker will be installed: +- registration.onupdatefound = handleInstallingWorker; ++ registration.addEventListener('updatefound', handleInstallingWorker); + } + + /** +diff --git a/node_modules/@dhis2/pwa/build/es/offline-interface/pwa-update-offline-interface.js b/node_modules/@dhis2/pwa/build/es/offline-interface/pwa-update-offline-interface.js +new file mode 100644 +index 0000000..b5e5147 +--- /dev/null ++++ b/node_modules/@dhis2/pwa/build/es/offline-interface/pwa-update-offline-interface.js +@@ -0,0 +1,133 @@ ++import EventEmitter from 'events'; ++import { swMsgs } from '../lib/constants.js'; ++import { checkForUpdates } from '../lib/registration.js'; ++ ++// todo: this could be used as a base for the other, larger offline interface ++ ++/** ++ * This and the following 'test' functions test for PWA features and log errors ++ * if there's an issue so they can be reused in the Offline Interface methods. ++ * ++ * Known situations when navigator.serviceWorker is not available: ++ * 1. Private browsing in firefox ++ * 2. Insecure contexts (e.g. http that's not local host) ++ */ ++function testSWAvailable(_ref) { ++ let { ++ targetWindow = window ++ } = _ref; ++ if ('serviceWorker' in targetWindow.navigator) { ++ return true; ++ } ++ const msg = (!targetWindow.isSecureContext ? 'This window is not a secure context -- see https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts.' : '`serviceWorker` is not available on `navigator`.') + ' PWA features will not work.'; ++ console.error(new Error(msg)); ++ return false; ++} ++ ++/** This exposes an interface to check a */ ++export class PWAUpdateOfflineInterface { ++ constructor(_ref2) { ++ let { ++ targetWindow = window ++ } = _ref2; ++ if (!testSWAvailable({ ++ targetWindow ++ })) { ++ return; ++ } ++ this.targetWindow = targetWindow; ++ this.serviceWorker = targetWindow.navigator.serviceWorker; ++ ++ // This event emitter helps coordinate with service worker messages ++ this.offlineEvents = new EventEmitter(); ++ // Receives messages from service worker and forwards to event emitter ++ const handleSWMessage = event => { ++ if (!event.data) { ++ return; ++ } ++ const { ++ type, ++ payload ++ } = event.data; ++ this.offlineEvents.emit(type, payload); ++ }; ++ this.serviceWorker.addEventListener('message', handleSWMessage); ++ } ++ ++ /** Basically `checkForUpdates` from registration.js exposed here */ ++ checkForNewSW(_ref3) { ++ let { ++ onNewSW ++ } = _ref3; ++ // Check for SW updates (or first activation) ++ checkForUpdates({ ++ onUpdate: onNewSW, ++ targetServiceWorker: this.serviceWorker ++ }); ++ } ++ ++ /** ++ * Requests clients info from the active service worker. ++ * @returns {Promise} ++ */ ++ getClientsInfo() { ++ if (!testSWAvailable({ ++ targetWindow: this.targetWindow ++ })) { ++ return Promise.resolve({ ++ clientsCount: 0 ++ }); ++ } ++ return new Promise((resolve, reject) => { ++ this.serviceWorker.getRegistration().then(registration => { ++ const newestSW = (registration === null || registration === void 0 ? void 0 : registration.waiting) || (registration === null || registration === void 0 ? void 0 : registration.active); ++ if (!newestSW) { ++ resolve({ ++ clientsCount: 0 ++ }); ++ return; ++ } ++ ++ // Send request message to newest SW ++ newestSW.postMessage({ ++ type: swMsgs.getClientsInfo ++ }); ++ // Resolve with payload received from SW `clientsInfo` message ++ this.offlineEvents.once(swMsgs.clientsInfo, resolve); ++ // Clean up potentially unused listeners eventually ++ setTimeout(() => { ++ reject('Request for clients info timed out'); ++ this.offlineEvents.removeAllListeners(swMsgs.clientsInfo); ++ }, 2000); ++ }); ++ }); ++ } ++ ++ /** ++ * Makes a new SW either skip waiting if it's an update, ++ * or claim clients if it's the first SW activation ++ */ ++ useNewSW() { ++ if (!testSWAvailable({ ++ targetWindow: this.targetWindow ++ })) { ++ return Promise.resolve(); ++ } ++ return this.serviceWorker.getRegistration().then(registration => { ++ if (!registration) { ++ throw new Error('No service worker is registered'); ++ } ++ if (registration.waiting) { ++ // Update existing service worker ++ registration.waiting.postMessage({ ++ type: swMsgs.skipWaiting ++ }); ++ } else if (registration.active) { ++ // (First SW activation) Have SW take control of clients ++ registration.active.postMessage({ ++ type: swMsgs.claimClients ++ }); ++ } ++ }); ++ } ++} diff --git a/node_modules/@dhis2/pwa/build/es/react/OfflineInterfaceContext.js b/node_modules/@dhis2/pwa/build/es/react/OfflineInterfaceContext.js new file mode 100644 index 0000000..ed7d292 @@ -182,7 +655,6 @@ index 0000000..ed7d292 + children: PropTypes.node +}; +export const useOfflineInterface = () => useContext(OfflineInterfaceContext); -\ No newline at end of file diff --git a/node_modules/@dhis2/pwa/build/es/react/usePWAUpdateState.js b/node_modules/@dhis2/pwa/build/es/react/usePWAUpdateState.js new file mode 100644 index 0000000..4f11c97 @@ -238,4 +710,26 @@ index 0000000..4f11c97 + onCancelUpdate + }; +}; -\ No newline at end of file +diff --git a/node_modules/@dhis2/pwa/build/es/service-worker/set-up-service-worker.js b/node_modules/@dhis2/pwa/build/es/service-worker/set-up-service-worker.js +index d6cd040..1b82e6d 100644 +--- a/node_modules/@dhis2/pwa/build/es/service-worker/set-up-service-worker.js ++++ b/node_modules/@dhis2/pwa/build/es/service-worker/set-up-service-worker.js +@@ -50,6 +50,8 @@ export function setUpServiceWorker() { + } = _ref; + return url.endsWith('index.html'); + }); ++ // Make sure that this request doesn't redirect to a global shell ++ indexHtmlManifestEntry.url += '?redirect=false'; + precache([indexHtmlManifestEntry]); + + // Custom strategy for handling app navigation, specifically to allow +@@ -81,7 +83,8 @@ export function setUpServiceWorker() { + // Return true to signal that we want to use the handler. + return true; + }; +- const indexUrl = process.env.PUBLIC_URL + '/index.html'; ++ // Above, the index entry had the redirect param added: ++ const indexUrl = process.env.PUBLIC_URL + '/index.html?redirect=false'; + const navigationRouteHandler = _ref3 => { + let { + request diff --git a/src/App.jsx b/src/App.jsx index ecb6769..6b2942c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,6 +5,7 @@ import { BrowserRouter, Routes, Route, Link, Outlet } from 'react-router-dom' import styles from './App.module.css' import { ConnectedHeaderBar } from './components/ConnectedHeaderbar.jsx' import { PluginLoader } from './components/PluginLoader.jsx' +import { ClientPWAProvider } from './lib/clientPWAUpdateState.jsx' const APPS_INFO_QUERY = { appMenu: { @@ -18,37 +19,18 @@ const APPS_INFO_QUERY = { // need to extend app-runtime to get that } -const Layout = ({ - clientPWAUpdateAvailable, - onApplyClientUpdate, - appsInfoQuery, -}) => { +const Layout = ({ appsInfoQuery }) => { return (
- +
) } -Layout.propTypes = { - appsInfoQuery: PropTypes.object, - clientPWAUpdateAvailable: PropTypes.bool, - onApplyClientUpdate: PropTypes.func, -} +Layout.propTypes = { appsInfoQuery: PropTypes.object } -// todo: also listen to navigations inside iframe (e.g. "Open this dashboard item in DV" links) -// (Could the `window` prop on BrowserRouter help here?) const MyApp = () => { const { baseUrl } = useConfig() - // todo: maybe pare this down to just onApplyUpdate? - // todo: reset upon switching to a new client app - const [clientPWAUpdateAvailable, setClientPWAUpdateAvailable] = - React.useState(false) - const [onApplyClientUpdate, setOnApplyClientUpdate] = React.useState() const appsInfoQuery = useDataQuery(APPS_INFO_QUERY) // todo: work on this to get the right URL when landing on an app URL @@ -64,36 +46,25 @@ const MyApp = () => { // (see getAppDisplayName in ConnectedHeaderBar.jsx and getAppDefaultAction in PluginLoader.jsx) return ( - - - + + + }> + Local App} + /> + + } /> - } - > - Local App} - /> - - } - /> - - - + + + + ) } diff --git a/src/components/ConfirmUpdateModal.jsx b/src/components/ConfirmUpdateModal.tsx similarity index 84% rename from src/components/ConfirmUpdateModal.jsx rename to src/components/ConfirmUpdateModal.tsx index d5b0bc2..06ae5a8 100644 --- a/src/components/ConfirmUpdateModal.jsx +++ b/src/components/ConfirmUpdateModal.tsx @@ -6,11 +6,20 @@ import { ModalContent, ModalTitle, } from '@dhis2/ui' -import PropTypes from 'prop-types' import React from 'react' import i18n from '../locales/index.js' -export function ConfirmUpdateModal({ clientsCount, onCancel, onConfirm }) { +interface ConfirmUpdateModalProps { + clientsCount: number + onCancel: () => void + onConfirm: () => void +} + +export function ConfirmUpdateModal({ + clientsCount, + onCancel, + onConfirm, +}: ConfirmUpdateModalProps) { return ( {i18n.t('Save your data')} @@ -36,8 +45,3 @@ export function ConfirmUpdateModal({ clientsCount, onCancel, onConfirm }) { ) } -ConfirmUpdateModal.propTypes = { - clientsCount: PropTypes.number, - onCancel: PropTypes.func, - onConfirm: PropTypes.func, -} diff --git a/src/components/ConnectedHeaderbar.jsx b/src/components/ConnectedHeaderbar.jsx index 125137f..a9fea3b 100644 --- a/src/components/ConnectedHeaderbar.jsx +++ b/src/components/ConnectedHeaderbar.jsx @@ -1,18 +1,10 @@ import { usePWAUpdateState } from '@dhis2/pwa' import { HeaderBar } from '@dhis2/ui' import PropTypes from 'prop-types' -import React from 'react' +import React, { useMemo, useEffect } from 'react' import { useParams } from 'react-router-dom' -import { ConfirmUpdateModal } from './ConfirmUpdateModal.jsx' - -/** - * Copied from ConnectedHeaderBar in app adapter: - * Check for SW updates or a first activation, displaying an update notification - * message in the HeaderBar profile menu. When an update is applied, if there are - * multiple tabs of this app open, there's anadditional warning step because all - * clients of the service worker will reload when there's an update, which may - * cause data loss. - */ +import { useClientPWAUpdateState } from '../lib/clientPWAUpdateState.jsx' +import { ConfirmUpdateModal } from './ConfirmUpdateModal.tsx' const getAppDisplayName = (appName, modules) => { // todo: check this out if core apps get a different naming scheme @@ -27,22 +19,32 @@ const getAppVersion = (appName, apps) => { return apps.find((a) => a.short_name === parsedAppName)?.version } -export function ConnectedHeaderBar({ - clientPWAUpdateAvailable, - onApplyClientUpdate, - appsInfoQuery, -}) { +// todo: +// type PWAUpdateState = { +// updateAvailable: boolean +// confirmReload: () => void +// confirmationRequired: boolean +// clientsCount: number | null +// onConfirmUpdate: () => void +// onCancelUpdate: () => void +// } + +/** + * Copied from ConnectedHeaderBar in app adapter: + * Check for SW updates or a first activation, displaying an update notification + * message in the HeaderBar profile menu. Does this both for this app itself, + * and for the client app. + * + * When an update is applied, if there are multiple tabs of this app open, + * there's anadditional warning step because all clients of the service worker + * will reload when there's an update, which may cause data loss. + */ +export function ConnectedHeaderBar({ appsInfoQuery }) { const params = useParams() - const { - updateAvailable: selfUpdateAvailable, - confirmReload, - confirmationRequired, - clientsCount, - onConfirmUpdate, - onCancelUpdate, - } = usePWAUpdateState() + const clientPWAUpdateState = useClientPWAUpdateState() + const selfPWAUpdateState = usePWAUpdateState() - const appName = React.useMemo(() => { + const appName = useMemo(() => { if (!params.appName || !appsInfoQuery.data) { // `undefined` defaults to app title in header bar component, i.e. "Global Shell" return @@ -57,36 +59,33 @@ export function ConnectedHeaderBar({ }, [appsInfoQuery.data, params.appName]) // Set new displayname to page title when it updates - React.useEffect(() => { + useEffect(() => { if (appName) { document.title = `${appName} | DHIS2` } }, [appName]) - const appVersion = React.useMemo(() => { + const appVersion = useMemo(() => { if (!params.appName || !appsInfoQuery.data) { return } return getAppVersion(params.appName, appsInfoQuery.data.apps) }, [appsInfoQuery.data, params.appName]) - // Choose the right handler - const handleApplyAvailableUpdate = React.useMemo(() => { - if (clientPWAUpdateAvailable && !selfUpdateAvailable) { - return onApplyClientUpdate - } - // If there's an update ready for both the global shell and the client, - // updating the global shell will handle the client updates as they - // will all get reloaded - return confirmReload - }, [ - clientPWAUpdateAvailable, - selfUpdateAvailable, + // For now, the header bar can only show one "Update available" badge, so + // choose the right values based on which update(s) is/are available: + // By default, use client's PWA update state. If there's an update available for + // the global shell, though, the self PWA update state takes precedent + const { + updateAvailable, confirmReload, - onApplyClientUpdate, - ]) - - const updateAvailable = selfUpdateAvailable || clientPWAUpdateAvailable + confirmationRequired, + clientsCount, + onConfirmUpdate, + onCancelUpdate, + } = selfPWAUpdateState.updateAvailable + ? selfPWAUpdateState + : clientPWAUpdateState return ( <> @@ -96,10 +95,8 @@ export function ConnectedHeaderBar({ // todo: currently not used by the component appVersion={appVersion} updateAvailable={updateAvailable} - onApplyAvailableUpdate={handleApplyAvailableUpdate} + onApplyAvailableUpdate={confirmReload} /> - {/* The following is used for global shell updates -- */} - {/* the client app will handle its own confirmation modal */} {confirmationRequired ? ( ) } -ConnectedHeaderBar.propTypes = { - appsInfoQuery: PropTypes.object, - clientPWAUpdateAvailable: PropTypes.bool, - onApplyClientUpdate: PropTypes.func, -} +ConnectedHeaderBar.propTypes = { appsInfoQuery: PropTypes.object } diff --git a/src/components/PluginLoader.jsx b/src/components/PluginLoader.jsx index b6c459e..440725b 100644 --- a/src/components/PluginLoader.jsx +++ b/src/components/PluginLoader.jsx @@ -4,6 +4,7 @@ import { Plugin } from '@dhis2/app-runtime/experimental' import PropTypes from 'prop-types' import React from 'react' import { useLocation, useParams } from 'react-router-dom' +import { useClientOfflineInterface } from '../lib/clientPWAUpdateState.jsx' import i18n from '../locales/index.js' import styles from './PluginLoader.module.css' @@ -75,6 +76,7 @@ const watchForHashRouteChanges = (event) => { * in the onLoad handler) * 2. The navigation is to another app: the backend will reroute to the global * shell with the right app + * 3. The backend redirects to the login page: send the app there * * This should be called on `load` events in the iframe, which indicates some * kind of page navigation; this won't trigger on hash route changes. @@ -89,11 +91,7 @@ const handleExternalNavigation = (iframeLoadEvent, pluginHref) => { } } -export const PluginLoader = ({ - setClientPWAUpdateAvailable, - setOnApplyClientUpdate, - appsInfoQuery, -}) => { +export const PluginLoader = ({ appsInfoQuery }) => { const params = useParams() const location = useLocation() const { baseUrl } = useConfig() @@ -105,6 +103,7 @@ export const PluginLoader = ({ ), { warning: true } ) + const initClientOfflineInterface = useClientOfflineInterface() // test prop messaging and updates const [color, setColor] = React.useState('blue') @@ -146,7 +145,10 @@ export const PluginLoader = ({ const handleLoad = React.useCallback( (event) => { // If we can't access the new page's Document, this is a cross-domain page. - // Disallow that; return to previous plugin state + // Disallow that; return to previous plugin state. + // todo: can cause an infinite reload if the current pluginHref loads + // an entirely broken page -- this is a rare case though; one example is + // a PWA app where the precache has been deleted. 404s are fine. if (!event.target.contentDocument) { setRerenderKey((k) => k + 1) showNavigationWarning() @@ -162,8 +164,11 @@ export const PluginLoader = ({ } injectHeaderbarHidingStyles(event) watchForHashRouteChanges(event) + initClientOfflineInterface({ + clientWindow: event.target.contentWindow, + }) }, - [pluginHref, showNavigationWarning] + [pluginHref, showNavigationWarning, initClientOfflineInterface] ) if (!pluginHref) { @@ -178,25 +183,10 @@ export const PluginLoader = ({ onLoad={handleLoad} key={rerenderKey} // Other props - reportPWAUpdateStatus={(data) => { - const { updateAvailable, onApplyUpdate } = data - console.log('recieved PWA status', { data }) - - setClientPWAUpdateAvailable(updateAvailable) - if (onApplyUpdate) { - // Return function from a function -- otherwise, setState tries to invoke the function - // to evaluate its next state - setOnApplyClientUpdate(() => onApplyUpdate) - } - }} - // props test + // (for testing:) color={color} toggleColor={toggleColor} /> ) } -PluginLoader.propTypes = { - appsInfoQuery: PropTypes.object, - setClientPWAUpdateAvailable: PropTypes.func, - setOnApplyClientUpdate: PropTypes.func, -} +PluginLoader.propTypes = { appsInfoQuery: PropTypes.object } diff --git a/src/lib/clientPWAUpdateState.jsx b/src/lib/clientPWAUpdateState.jsx new file mode 100644 index 0000000..bc38075 --- /dev/null +++ b/src/lib/clientPWAUpdateState.jsx @@ -0,0 +1,98 @@ +import { PWAUpdateOfflineInterface } from '@dhis2/pwa' +import PropTypes from 'prop-types' +import React, { + useState, + useCallback, + createContext, + useContext, + useMemo, +} from 'react' + +const ClientPWAUpdateStateContext = createContext() +const ClientOfflineInterfaceContext = createContext() + +export const ClientPWAProvider = ({ children }) => { + const [offlineInterface, setOfflineInterface] = useState() + const [updateAvailable, setUpdateAvailable] = useState(false) + const [clientsCount, setClientsCount] = useState(null) + + const onConfirmUpdate = useCallback(() => { + offlineInterface.useNewSW() + }, [offlineInterface]) + const onCancelUpdate = useCallback(() => { + setClientsCount(null) + }, []) + + const confirmReload = useCallback(() => { + offlineInterface + .getClientsInfo() + .then(({ clientsCount }) => { + if (clientsCount === 1) { + // Just one client; go ahead and reload + onConfirmUpdate() + } else { + // Multiple clients; warn about data loss before reloading + setClientsCount(clientsCount) + } + }) + .catch((reason) => { + // Didn't get clients info + console.warn(reason) + + // Go ahead with confirmation modal with `0` as clientsCount + setClientsCount(0) + }) + }, [offlineInterface, onConfirmUpdate]) + + const confirmationRequired = clientsCount !== null + const clientPWAUpdateState = useMemo( + () => ({ + updateAvailable, + confirmReload, + confirmationRequired, + clientsCount, + onConfirmUpdate, + onCancelUpdate, + }), + [ + updateAvailable, + confirmReload, + confirmationRequired, + clientsCount, + onConfirmUpdate, + onCancelUpdate, + ] + ) + + const initClientOfflineInterface = useCallback(({ clientWindow }) => { + const newOfflineInterface = new PWAUpdateOfflineInterface({ + targetWindow: clientWindow, + }) + // Reset this, if it's keeping a dialog open from a previous reload + setClientsCount(null) + setOfflineInterface(newOfflineInterface) + newOfflineInterface.checkForNewSW({ + onNewSW: () => { + setUpdateAvailable(true) + }, + }) + }, []) + + return ( + + + {children} + + + ) +} +ClientPWAProvider.propTypes = { children: PropTypes.node } + +export const useClientPWAUpdateState = () => + useContext(ClientPWAUpdateStateContext) + +/** Returns initClientOfflineInterface() */ +export const useClientOfflineInterface = () => + useContext(ClientOfflineInterfaceContext) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..aafc527 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +// drawn from https://www.totaltypescript.com/tsconfig-cheat-sheet +{ + "compilerOptions": { + /* Base Options: */ + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es2022", + "allowJs": true, + "resolveJsonModule": true, + "moduleDetection": "force", + "isolatedModules": true, + "verbatimModuleSyntax": true, + + /* Strictness */ + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + /* If NOT transpiling with TypeScript: */ + "module": "preserve", + "noEmit": true, + + /* If your code runs in the DOM: */ + "lib": ["es2022", "dom", "dom.iterable"], + + // Other things relevant to this projext: + "jsx": "react", + } +} diff --git a/yarn.lock b/yarn.lock index b011cc7..ce8b8de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1840,45 +1840,45 @@ "@dhis2/pwa" "12.3.0" moment "^2.24.0" -"@dhis2/app-runtime@^3.12.0", "@dhis2/app-runtime@^3.13.2": - version "3.13.2" - resolved "https://registry.yarnpkg.com/@dhis2/app-runtime/-/app-runtime-3.13.2.tgz#c3eded902b457fb35d949e05ccb5c17191f05e56" - integrity sha512-rY0DfADBytx5WD6cf8nYnjyzUmsEORxMUL20iIFgAQQ64H3073FI4GOJB11Xt9NF5cCJhtQLJsehAq8zhI7GUQ== - dependencies: - "@dhis2/app-service-alerts" "3.13.2" - "@dhis2/app-service-config" "3.13.2" - "@dhis2/app-service-data" "3.13.2" - "@dhis2/app-service-offline" "3.13.2" - "@dhis2/app-service-plugin" "3.13.2" - -"@dhis2/app-service-alerts@3.13.2": - version "3.13.2" - resolved "https://registry.yarnpkg.com/@dhis2/app-service-alerts/-/app-service-alerts-3.13.2.tgz#f8850bbd6c36d1fd62fbd9c1502e73f6def9f9ce" - integrity sha512-7q87wKQAy+xxHtxhsowCL28bncNKsyX1wLkMiwuVQvDwWwgzdAN90csF3RtBRIoV3FUqdTiMymyqrqiu7gDgSg== - -"@dhis2/app-service-config@3.13.2": - version "3.13.2" - resolved "https://registry.yarnpkg.com/@dhis2/app-service-config/-/app-service-config-3.13.2.tgz#350cb36d3bb19fbc24e801038cefabb39b3756eb" - integrity sha512-dejWJUwQJTr+3Nxt/NRlcuCpIHV6HD8YYCSXFcL+yCxIM8GCyUAWzjR/B9nja3K6/MbN74yGMuozmyYqScBdUw== - -"@dhis2/app-service-data@3.13.2": - version "3.13.2" - resolved "https://registry.yarnpkg.com/@dhis2/app-service-data/-/app-service-data-3.13.2.tgz#e3e0201a916dfe9cca8b5caa3d1b5eabda154534" - integrity sha512-awwta3LQywTjGQF4McUYr1oJFoTc8SSBPH5FJ4L60aivNkMFXoUTUJ629eC+xaPmuuvn5O712jGDFPGlTJO4bg== +"@dhis2/app-runtime@^3.12.0", "@dhis2/app-runtime@^3.14.0": + version "3.14.0" + resolved "https://registry.yarnpkg.com/@dhis2/app-runtime/-/app-runtime-3.14.0.tgz#85a6f337036ba31868fd7193360208ef174d4345" + integrity sha512-0qmP2QxhoK8dWCAOOomD+JDer44AC5rxOHuK5qINNt1K4BMZgG5axmkvk6/86SXTRtLj2+Iio7BLe9DBnh+Bxg== + dependencies: + "@dhis2/app-service-alerts" "3.14.0" + "@dhis2/app-service-config" "3.14.0" + "@dhis2/app-service-data" "3.14.0" + "@dhis2/app-service-offline" "3.14.0" + "@dhis2/app-service-plugin" "3.14.0" + +"@dhis2/app-service-alerts@3.14.0": + version "3.14.0" + resolved "https://registry.yarnpkg.com/@dhis2/app-service-alerts/-/app-service-alerts-3.14.0.tgz#93eab29b3e42115e9de509bec7daa172cc50a0f4" + integrity sha512-VhXc0w5fX+2Rd4J7MZmklnl7Fq7PLnCrSYDSWRZPYXbNo428QjVIiN/1g003gCatVPon3bl19LisZbn5vmNoRg== + +"@dhis2/app-service-config@3.14.0": + version "3.14.0" + resolved "https://registry.yarnpkg.com/@dhis2/app-service-config/-/app-service-config-3.14.0.tgz#823ce5e96c6deb8beec92bd65180ca611f0551a0" + integrity sha512-v/gnPMG7IZ6pEutYnHJff4S93w1DAQbeDiGtRqMffEiNxa9Bx5wc5MPTGMyeUGeEw8on3qaeP/2rQa+PGaQvcg== + +"@dhis2/app-service-data@3.14.0": + version "3.14.0" + resolved "https://registry.yarnpkg.com/@dhis2/app-service-data/-/app-service-data-3.14.0.tgz#7109852154af0b02240e077df9ae5e65a727a593" + integrity sha512-7DQVlVSCdJauduw+9LXrb13IsNagGzPNN2Iy9e2jVeE+eNqP6j0PeI6enHTIjaYA6OOSx/Ob7/HXkBVH8wyLlg== dependencies: "@tanstack/react-query" "^4.36.1" -"@dhis2/app-service-offline@3.13.2": - version "3.13.2" - resolved "https://registry.yarnpkg.com/@dhis2/app-service-offline/-/app-service-offline-3.13.2.tgz#76a42f5b501045872f7d0527c8891e701d33bf3c" - integrity sha512-yBvEL+ZpALfnJIkEFhfd6oYGx6esq4QSbs604O1BpajR6CjlPzmSTwKqEp9QGcXv+PEyoKnwR0w/t/gxPKtkXw== +"@dhis2/app-service-offline@3.14.0": + version "3.14.0" + resolved "https://registry.yarnpkg.com/@dhis2/app-service-offline/-/app-service-offline-3.14.0.tgz#9164f4ef1ffef18d52a77f36bfd3683b5ccbdb81" + integrity sha512-sgp7AtaPIvNt5TeXCrVR2/ndtgx9p+TstQWiBzRq75R894P34nqBX9jWMVUoNDZU35OfcGqtxHfOrpoLrdZ0XA== dependencies: lodash "^4.17.21" -"@dhis2/app-service-plugin@3.13.2": - version "3.13.2" - resolved "https://registry.yarnpkg.com/@dhis2/app-service-plugin/-/app-service-plugin-3.13.2.tgz#7d71c909c433fc7078700e7511552716adf7e86c" - integrity sha512-3RDdbslRj4yU3XTvpoaV+F0Fg1CrM74zByn0so3NzIizKhjtzAuVBdRpluml/W6qkwrkCLk3U0oqssIURapDfg== +"@dhis2/app-service-plugin@3.14.0": + version "3.14.0" + resolved "https://registry.yarnpkg.com/@dhis2/app-service-plugin/-/app-service-plugin-3.14.0.tgz#ea6ed4afc805072352e861e5412223b056e2edcf" + integrity sha512-3D9iPG6/HFT1dY7G6Wwwunv2bQ3hMzW2gsw8rFAcOphFHMujzQYUYuaZQmxg7rs/Hh/DPzLVOzsNAqOi1cS7wA== dependencies: post-robot "^10.0.46" @@ -1977,10 +1977,10 @@ update-notifier "^3.0.0" yargs "^13.1.0" -"@dhis2/cli-style@^10.7.5": - version "10.7.5" - resolved "https://registry.yarnpkg.com/@dhis2/cli-style/-/cli-style-10.7.5.tgz#d2794436723500d02d017b1849ec0e39d195b53b" - integrity sha512-M8K0vmtC8nHwpb7D4Njte8z7MJgpMrgl8BkxI3Y4NeCwun+QVMszCDF0xZQG32LGnsQTNwLvPDilyXTWN/NWLA== +"@dhis2/cli-style@^10.7.6": + version "10.7.6" + resolved "https://registry.yarnpkg.com/@dhis2/cli-style/-/cli-style-10.7.6.tgz#70bd7f1995ae31d6aecaaf4233ecff290a3840ef" + integrity sha512-MAu7zEVH5JaENlfwnIjRrrEiNUqpYBDLuOg+y209zpc9YNT+Cn1mnAXRzzdIa4Tlg0eGn+fGdlL9e+pOpb9IUA== dependencies: "@commitlint/cli" "^12.1.4" "@commitlint/config-conventional" "^13.1.0" @@ -3045,6 +3045,13 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.7.tgz#50ae4353eaaddc04044279812f52c8c65857dbcb" integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== +"@types/react@^19.0.10": + version "19.0.10" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.10.tgz#d0c66dafd862474190fe95ce11a68de69ed2b0eb" + integrity sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g== + dependencies: + csstype "^3.0.2" + "@types/resolve@1.20.2": version "1.20.2" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.2.tgz#97d26e00cd4a0423b4af620abecf3e6f442b7975" @@ -4806,6 +4813,11 @@ cssstyle@^2.3.0: dependencies: cssom "~0.3.6" +csstype@^3.0.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== + dargs@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/dargs/-/dargs-7.0.0.tgz#04015c41de0bcb69ec84050f3d9be0caf8d6d5cc" @@ -11030,6 +11042,11 @@ typescript@^5.6.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.3.tgz#5f3449e31c9d94febb17de03cc081dd56d81db5b" integrity sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw== +typescript@^5.7.3: + version "5.7.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.3.tgz#919b44a7dbb8583a9b856d162be24a54bf80073e" + integrity sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw== + uglify-js@^3.1.4: version "3.17.4" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.4.tgz#61678cf5fa3f5b7eb789bb345df29afb8257c22c"