Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reset Application Form on Error or on Version-Change #637

Merged
merged 7 commits into from
Dec 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions administration/application_commit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/bin/bash

# This script determines the last commit of the folder administration/src/application.
# This is used as a version for the application form. The form should reset whenever there are changes to the source
# code of the application form.

if ! command -v git &> /dev/null
then
>&2 echo "Git is not installed."
exit 1
fi

basedir=$(dirname "$0")
michael-markl marked this conversation as resolved.
Show resolved Hide resolved
commit=$(git log -n 1 --format="%h" -- "${basedir}/src/application")
if [ -z "$commit" ]
then
>&2 echo "Could not determine last application commit!"
exit 1
else
echo "$commit"
fi
4 changes: 2 additions & 2 deletions administration/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@
"typescript": "^4.8.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"start": "set -ae; REACT_APP_APPLICATION_COMMIT=$(./application_commit.sh); react-scripts start",
maxammann marked this conversation as resolved.
Show resolved Hide resolved
"build": "set -ae; REACT_APP_APPLICATION_COMMIT=$(./application_commit.sh); react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"lint": "eslint . && prettier . --check",
Expand Down
35 changes: 35 additions & 0 deletions administration/src/application/ApplicationErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import localforage from 'localforage'
import { Component, ReactNode } from 'react'
import { applicationStorageKey } from './components/ApplyController'
import { globalArrayBuffersKey } from './globalArrayBuffersManager'

class ApplicationErrorBoundary extends Component<{ children: ReactNode }, { resetting: boolean }> {
state = { resetting: false }

async componentDidCatch() {
this.setState({ resetting: true })
try {
await localforage.removeItem(applicationStorageKey)
await localforage.removeItem(globalArrayBuffersKey)
} catch (e) {
console.error('Another error occurred while resetting the application state.', e)
} finally {
this.setState({ resetting: false })
}
}

render() {
if (this.state.resetting) return null
michael-markl marked this conversation as resolved.
Show resolved Hide resolved
return this.props.children
}

static getDerivedStateFromError(error: Error) {
console.error(
'An error occurred while rendering the application form. Resetting the stored application state...',
error
)
return { resetting: true }
}
}

export default ApplicationErrorBoundary
26 changes: 18 additions & 8 deletions administration/src/application/components/ApplyController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,38 @@ import '@fontsource/roboto/700.css'

import { useAddBlueEakApplicationMutation } from '../../generated/graphql'
import { DialogActions } from '@mui/material'
import useLocallyStoredState from '../useLocallyStoredState'
import useVersionedLocallyStoredState from '../useVersionedLocallyStoredState'
import DiscardAllInputsButton from './DiscardAllInputsButton'
import { useGarbageCollectArrayBuffers, useInitializeGlobalArrayBuffersManager } from '../globalArrayBuffersManager'
import ApplicationForm from './forms/ApplicationForm'
import { useCallback, useMemo } from 'react'
import { SnackbarProvider, useSnackbar } from 'notistack'
import ApplicationErrorBoundary from '../ApplicationErrorBoundary'

const applicationStorageKey = 'applicationState'
// This env variable is determined by '../../../application_commit.sh'. It holds the hash of the last commit to the
// application form.
const lastCommitForApplicationForm = process.env.REACT_APP_APPLICATION_COMMIT as string
michael-markl marked this conversation as resolved.
Show resolved Hide resolved

export const applicationStorageKey = 'applicationState'

const ApplyController = () => {
const [addBlueEakApplication, { loading }] = useAddBlueEakApplicationMutation()
const [state, setState] = useLocallyStoredState(ApplicationForm.initialState, applicationStorageKey)
const { status, state, setState } = useVersionedLocallyStoredState(
ApplicationForm.initialState,
applicationStorageKey,
lastCommitForApplicationForm
)
const arrayBufferManagerInitialized = useInitializeGlobalArrayBuffersManager()
const getArrayBufferKeys = useMemo(
() => (state === null ? null : () => ApplicationForm.getArrayBufferKeys(state)),
[state]
() => (status === 'loading' ? null : () => ApplicationForm.getArrayBufferKeys(state)),
[state, status]
)
const { enqueueSnackbar } = useSnackbar()
useGarbageCollectArrayBuffers(getArrayBufferKeys)

const discardAll = useCallback(() => setState(() => ApplicationForm.initialState), [setState])

// state is null, if it's still being loaded from storage (e.g. after a page reload)
if (state == null || !arrayBufferManagerInitialized) {
if (status === 'loading' || !arrayBufferManagerInitialized) {
return null
}

Expand Down Expand Up @@ -64,7 +72,9 @@ const ApplyController = () => {

const ApplyApp = () => (
<SnackbarProvider>
<ApplyController />
<ApplicationErrorBoundary>
<ApplyController />
</ApplicationErrorBoundary>
</SnackbarProvider>
)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import localforage from 'localforage'
import { useEffect, useRef, useState } from 'react'

const globalArrayBuffersKey = 'array-buffers'
export const globalArrayBuffersKey = 'array-buffers'

class ArrayBuffersManager {
private arrayBuffers: { key: number; value: ArrayBuffer }[] = []
Expand Down
47 changes: 0 additions & 47 deletions administration/src/application/useLocallyStoredState.ts

This file was deleted.

106 changes: 106 additions & 0 deletions administration/src/application/useVersionedLocallyStoredState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import localforage from 'localforage'
import { useCallback, useEffect, useRef, useState } from 'react'
import { SetState, useUpdateStateCallback } from './useUpdateStateCallback'

function useLocallyStoredState<T>(
initialState: T,
storageKey: string
): {
status: 'loading' | 'ready'
state: T
setState: SetState<T>
} {
const [state, setState] = useState(initialState)
const stateRef = useRef(state)
const [status, setStatus] = useState<'loading' | 'ready'>('loading')

useEffect(() => {
localforage
.getItem<T>(storageKey)
.then(storedValue => {
if (storedValue !== null) {
stateRef.current = storedValue
setState(storedValue)
}
})
.finally(() => setStatus('ready'))
}, [storageKey, setState, stateRef])

const setStateAndRef = useCallback(
(update: (oldState: T) => T) => {
if (status !== 'loading') {
setState(state => {
const newState = update(state)
stateRef.current = newState
return newState
})
}
},
[status]
)

useEffect(() => {
// Auto-save every 2 seconds unless we're still loading the state.
if (status === 'loading') {
return
}
let lastState: T | null = null
const interval = setInterval(() => {
if (lastState !== stateRef.current) {
localforage.setItem(storageKey, stateRef.current)
lastState = stateRef.current
}
}, 2000)
return () => clearInterval(interval)
}, [status, storageKey])

return { status, state, setState: setStateAndRef }
}

/**
* Locally stores some state together with a version using localforage.
* Auto-saves any update every 2 seconds.
* Initially, it
* - returns the locally stored state, if there exists one and if the stored version matches the passed version, or
* - returns the passed initial state otherwise.
* While localforage is loading, the returned status is set to `loading` otherwise to `ready`.
*/
function useVersionedLocallyStoredState<T>(
initialState: T,
storageKey: string,
version: string
): {
status: 'loading' | 'ready'
state: T
setState: SetState<T>
} {
const {
status: locallyStoredStatus,
setState: setLocallyStoredState,
state: locallyStoredState,
} = useLocallyStoredState({ version, value: initialState }, storageKey)
const locallyStoredVersion =
typeof locallyStoredState === 'object' && 'version' in locallyStoredState
? locallyStoredState.version
: '(could not determine)'
useEffect(() => {
if (locallyStoredStatus === 'ready' && locallyStoredVersion !== version) {
console.warn(
`Resetting storage because of version mismatch: \n` +
`Locally stored version: ${locallyStoredVersion}.\n` +
`New version: ${version}.`
)
setLocallyStoredState(() => ({ version, value: initialState }))
}
}, [locallyStoredVersion, version, locallyStoredStatus, initialState, setLocallyStoredState])
const setState: SetState<T> = useUpdateStateCallback(setLocallyStoredState, 'value')
if (locallyStoredStatus === 'loading' || locallyStoredVersion !== version)
return {
status: 'loading',
state: initialState,
setState,
}
return { status: 'ready', state: locallyStoredState.value, setState }
}

export default useVersionedLocallyStoredState