Skip to content

Commit

Permalink
feat(pwa): track online status [LIBS-315] (#718)
Browse files Browse the repository at this point in the history
* fix: migrate to IDB to store dhis2 base url

* refactor: init function

* fix: add complete env to dev SW

* refactor: get base url api

* feat(pwa): use SW to track dhis2 server connection (most strategies)

* refactor(sw): convert recording mode to strategy & use status plugin

* refactor: rename online status to dhis2 connection status for clarity

* fix(deps): use latest app-runtime

* chore(pwa-app): add request tester

* feat(offline-interface): add subscription to connection status from SW

* chore(pwa-app): temporarily remove dependency

* fix: fallback to localStorage for baseUrl to avoid breaking change

* chore: format

* chore: formatting

* chore: add some tools for testing connection status

* fix: exclude pings from cache

* fix: treat 401 as disconnected

* fix: don't check auth

* refactor: make reusable getAllClientsInScope fn

* refactor: clean up

* fix: throttle status broadcasts

* fix: use shorter isConnected name

* chore: rename set-up-service-worker

* fix: change subscription callback name

* chore: some more dev utils

* chore: satisfy linter

* chore: update comments

* refactor: clarify systemInfo and baseUrl states

* chore: clarify todos

* chore: update comment

* fix: wait for offline interface to be ready for connection status

* chore: fix typo

* chore: update temp test scripts

* chore: remove console logs

* chore: clear up test scripts

* fix(sw): use NetworkOnly for new ping endpoint

* feat: use connection status from app runtime
  • Loading branch information
KaiVandivier committed Mar 3, 2023
1 parent cba768a commit 1dfd1e6
Show file tree
Hide file tree
Showing 17 changed files with 455 additions and 90 deletions.
16 changes: 12 additions & 4 deletions adapter/src/components/LoginModal.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { setBaseUrlByAppName } from '@dhis2/pwa'
import {
Modal,
ModalTitle,
Expand All @@ -6,16 +7,16 @@ import {
Button,
InputField,
} from '@dhis2/ui'
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import i18n from '../locales/index.js'
import { post } from '../utils/api.js'

// Check if base URL is set statically as an env var (typical in production)
const staticUrl = process.env.REACT_APP_DHIS2_BASE_URL

export const LoginModal = () => {
const [server, setServer] = useState(
staticUrl || window.localStorage.DHIS2_BASE_URL || ''
)
export const LoginModal = ({ appName, baseUrl }) => {
const [server, setServer] = useState(baseUrl || '')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [isDirty, setIsDirty] = useState(false)
Expand All @@ -27,7 +28,10 @@ export const LoginModal = () => {
setIsDirty(true)
if (isValid(server) && isValid(username) && isValid(password)) {
if (!staticUrl) {
// keep the localStorage value here -- it's still used in some
// obscure cases, like in the cypress network shim
window.localStorage.DHIS2_BASE_URL = server
await setBaseUrlByAppName({ appName, baseUrl: server })
}
try {
await post(
Expand Down Expand Up @@ -99,3 +103,7 @@ export const LoginModal = () => {
</Modal>
)
}
LoginModal.propTypes = {
appName: PropTypes.string,
baseUrl: PropTypes.string,
}
118 changes: 104 additions & 14 deletions adapter/src/components/ServerVersionProvider.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Provider } from '@dhis2/app-runtime'
import { getBaseUrlByAppName, setBaseUrlByAppName } from '@dhis2/pwa'
import PropTypes from 'prop-types'
import React, { useEffect, useState } from 'react'
import { get } from '../utils/api.js'
Expand All @@ -10,45 +11,134 @@ import { useOfflineInterface } from './OfflineInterfaceContext.js'
export const ServerVersionProvider = ({
appName,
appVersion,
url,
url, // url from env vars
apiVersion,
pwaEnabled,
children,
}) => {
const offlineInterface = useOfflineInterface()
const [{ loading, error, systemInfo }, setState] = useState({
const [systemInfoState, setSystemInfoState] = useState({
loading: true,
error: undefined,
systemInfo: undefined,
})
const [baseUrlState, setBaseUrlState] = useState({
loading: !url,
error: undefined,
baseUrl: url,
})
const [offlineInterfaceLoading, setOfflineInterfaceLoading] = useState(true)
const { systemInfo } = systemInfoState
const { baseUrl } = baseUrlState

useEffect(() => {
if (!url) {
setState({ loading: false, error: new Error('No url specified') })
// if URL prop is not set, set state to error to show login modal.
// Submitting valid login form with server and credentials reloads page,
// ostensibly with a filled url prop (now persisted locally)
if (!baseUrl) {
// Use a function as the argument to avoid needing baseUrlState as
// a dependency for useEffect
setBaseUrlState((state) =>
state.loading
? state
: { loading: true, error: undefined, systemInfo: undefined }
)
// try getting URL from IndexedDB
getBaseUrlByAppName(appName)
.then((baseUrlFromDB) => {
if (baseUrlFromDB) {
// Set baseUrl in state if found in DB
setBaseUrlState({
loading: false,
error: undefined,
baseUrl: baseUrlFromDB,
})
return
}
// If no URL found in DB, try localStorage
// (previous adapter versions stored the base URL there)
const baseUrlFromLocalStorage =
window.localStorage.DHIS2_BASE_URL
if (baseUrlFromLocalStorage) {
setBaseUrlState({
loading: false,
error: undefined,
baseUrl: baseUrlFromLocalStorage,
})
// Also set it in IndexedDB for SW to access
return setBaseUrlByAppName({
appName,
baseUrl: baseUrlFromLocalStorage,
})
}
// If no base URL found in either, set error to show login modal
setBaseUrlState({
loading: false,
error: new Error('No url specified'),
baseUrl: undefined,
})
})
.catch((err) => {
console.error(err)
setBaseUrlState({
loading: false,
error: err,
baseUrl: undefined,
})
})

return
}

setState((state) => (state.loading ? state : { loading: true }))
const request = get(`${url}/api/system/info`)
// If url IS set, try querying API to test authentication and get
// server version. If it fails, set error to show login modal

setSystemInfoState((state) =>
state.loading
? state
: { loading: true, error: undefined, systemInfo: undefined }
)
const request = get(`${baseUrl}/api/system/info`)
request
.then((systemInfo) => {
setState({ loading: false, systemInfo })
setSystemInfoState({
loading: false,
error: undefined,
systemInfo: systemInfo,
})
})
.catch((e) => {
// Todo: If this is a network error, the app cannot load -- handle that gracefully here
// if (e === 'Network error') { ... }
setState({ loading: false, error: e })
setSystemInfoState({
loading: false,
error: e,
systemInfo: undefined,
})
})

return () => {
request.abort()
}
}, [url])
}, [appName, baseUrl])

if (loading) {
return <LoadingMask />
useEffect(() => {
offlineInterface.ready.then(() => {
setOfflineInterfaceLoading(false)
})
}, [offlineInterface])

// This needs to come before 'loading' case to show modal at correct times
if (systemInfoState.error || baseUrlState.error) {
return <LoginModal appName={appName} baseUrl={baseUrl} />
}

if (error) {
return <LoginModal />
if (
systemInfoState.loading ||
baseUrlState.loading ||
offlineInterfaceLoading
) {
return <LoadingMask />
}

const serverVersion = parseDHIS2ServerVersion(systemInfo.version)
Expand All @@ -59,7 +149,7 @@ export const ServerVersionProvider = ({
config={{
appName,
appVersion: parseVersion(appVersion),
baseUrl: url,
baseUrl,
apiVersion: apiVersion || realApiVersion,
serverVersion,
systemInfo,
Expand Down
11 changes: 3 additions & 8 deletions cli/src/lib/pwa/compileServiceWorker.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const path = require('path')
const { reporter } = require('@dhis2/cli-helpers-engine')
const webpack = require('webpack')
const getEnv = require('../shell/env')
const getPWAEnvVars = require('./getPWAEnvVars')

/**
Expand Down Expand Up @@ -34,13 +35,7 @@ function compileServiceWorker({ config, paths, mode }) {
// TODO: This could be cleaner if the production SW is built in the same
// way instead of using the CRA webpack config, so both can more easily
// share environment variables.
const prefixedPWAEnvVars = Object.entries(getPWAEnvVars(config)).reduce(
(output, [key, value]) => ({
...output,
[`REACT_APP_DHIS2_APP_${key.toUpperCase()}`]: value,
}),
{}
)
const env = getEnv({ name: config.title, ...getPWAEnvVars(config) })

const webpackConfig = {
mode, // "production" or "development"
Expand All @@ -54,7 +49,7 @@ function compileServiceWorker({ config, paths, mode }) {
new webpack.DefinePlugin({
'process.env': JSON.stringify({
...process.env,
...prefixedPWAEnvVars,
...env,
}),
}),
],
Expand Down
3 changes: 2 additions & 1 deletion examples/pwa-app/d2.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ const config = {

entryPoints: {
app: './src/App.js',
plugin: './src/components/VisualizationsList.js',
// Uncomment this to test plugin builds:
// plugin: './src/components/VisualizationsList.js',
},
}

Expand Down
2 changes: 2 additions & 0 deletions examples/pwa-app/src/App.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import React from 'react'
import classes from './App.module.css'
import RequestTester from './components/RequestTester.js'
import SectionWrapper from './components/SectionWrapper.js'

const MyApp = () => (
<div className={classes.container}>
<RequestTester />
<SectionWrapper id={'section-id-01'} />
</div>
)
Expand Down
53 changes: 53 additions & 0 deletions examples/pwa-app/src/components/RequestTester.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useDataEngine, useDhis2ConnectionStatus } from '@dhis2/app-runtime'
import { Box, Button, ButtonStrip, Help } from '@dhis2/ui'
import React from 'react'

const query = {
me: {
resource: 'me',
params: {
fields: ['id', 'name'],
},
},
}

export default function RequestTester() {
const engine = useDataEngine()
const { isConnected, lastConnected } = useDhis2ConnectionStatus()

const internalRequest = () => {
console.log('Request tester: internal request')
engine.query(query)
}
const externalRequest = () => {
console.log('Request tester: external request')
fetch('https://random.dog/woof.json')
}

return (
<div>
<div>
Connection to DHIS2 server:{' '}
{isConnected ? (
<span style={{ color: 'green' }}>Connected</span>
) : (
<span style={{ color: 'red' }}>NOT CONNECTED</span>
)}
</div>
<div>
Last connected: {lastConnected?.toLocaleTimeString() || 'null'}
</div>
<Help>Based on useDhis2ConnectionStatus()</Help>
<Box marginTop={'12px'}>
<ButtonStrip>
<Button onClick={internalRequest}>
Query DHIS2 server
</Button>
<Button onClick={externalRequest}>
Query external server
</Button>
</ButtonStrip>
</Box>
</div>
)
}
3 changes: 2 additions & 1 deletion pwa/src/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { setUpServiceWorker } from './service-worker/service-worker.js'
export { setUpServiceWorker } from './service-worker/set-up-service-worker.js'
export { OfflineInterface } from './offline-interface/offline-interface.js'
export {
checkForUpdates,
Expand All @@ -9,3 +9,4 @@ export {
REGISTRATION_STATE_ACTIVE,
REGISTRATION_STATE_FIRST_ACTIVATION,
} from './lib/registration.js'
export { getBaseUrlByAppName, setBaseUrlByAppName } from './lib/base-url-db.js'
48 changes: 48 additions & 0 deletions pwa/src/lib/base-url-db.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { openDB /* deleteDB */ } from 'idb'

export const BASE_URL_DB = 'dhis2-base-url-db'
export const BASE_URL_STORE = 'dhis2-base-url-store'

const DB_VERSION = 1

/**
* Opens indexed DB and object store for baser urls by app name. Should be used any
* time the DB is accessed to make sure object stores are set up correctly and
* avoid DB-access race condition on first installation.
*
* @returns {Promise} dbPromise. Usage: `const db = await dbPromise`
*/
function openBaseUrlsDB() {
return openDB(BASE_URL_DB, DB_VERSION, {
upgrade(db, oldVersion /* newVersion, transaction */) {
// DB versioning trick that can iteratively apply upgrades
// https://developers.google.com/web/ilt/pwa/working-with-indexeddb#using_database_versioning
switch (oldVersion) {
case 0: {
db.createObjectStore(BASE_URL_STORE, {
keyPath: 'appName',
})
}
// falls through (this comment satisfies eslint)
default: {
console.debug('[sections-db] Done upgrading DB')
}
}
},
})
}

/** Deletes the DB (probably not needed) */
// function deleteBaseUrlsDB() {
// return deleteDB(BASE_URL_DB)
// }

export async function setBaseUrlByAppName({ appName, baseUrl }) {
const db = await openBaseUrlsDB()
return db.put(BASE_URL_STORE, { appName, baseUrl })
}

export async function getBaseUrlByAppName(appName) {
const db = await openBaseUrlsDB()
return db.get(BASE_URL_STORE, appName).then((entry) => entry?.baseUrl)
}
3 changes: 3 additions & 0 deletions pwa/src/lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,7 @@ export const swMsgs = Object.freeze({
confirmRecordingCompletion: 'CONFIRM_RECORDING_COMPLETION',
completeRecording: 'COMPLETE_RECORDING',
recordingCompleted: 'RECORDING_COMPLETED',
dhis2ConnectionStatusUpdate: 'DHIS2_CONNECTION_STATUS_UPDATE',
getImmediateDhis2ConnectionStatusUpdate:
'GET_IMMEDIATE_DHIS2_CONNECTION_STATUS_UPDATE',
})
Loading

0 comments on commit 1dfd1e6

Please sign in to comment.