diff --git a/administration/application_commit.sh b/administration/application_commit.sh
new file mode 100755
index 000000000..8183bfd39
--- /dev/null
+++ b/administration/application_commit.sh
@@ -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")
+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
diff --git a/administration/package.json b/administration/package.json
index 6e1365323..1c9f7dc4b 100644
--- a/administration/package.json
+++ b/administration/package.json
@@ -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",
+ "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",
diff --git a/administration/src/application/ApplicationErrorBoundary.tsx b/administration/src/application/ApplicationErrorBoundary.tsx
new file mode 100644
index 000000000..3500f62da
--- /dev/null
+++ b/administration/src/application/ApplicationErrorBoundary.tsx
@@ -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
+ 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
diff --git a/administration/src/application/components/ApplyController.tsx b/administration/src/application/components/ApplyController.tsx
index 526bfa19d..d6c0ac3d9 100644
--- a/administration/src/application/components/ApplyController.tsx
+++ b/administration/src/application/components/ApplyController.tsx
@@ -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
+
+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
}
@@ -64,7 +72,9 @@ const ApplyController = () => {
const ApplyApp = () => (
-
+
+
+
)
diff --git a/administration/src/application/globalArrayBuffersManager.ts b/administration/src/application/globalArrayBuffersManager.ts
index 5e5633f40..d9564544a 100644
--- a/administration/src/application/globalArrayBuffersManager.ts
+++ b/administration/src/application/globalArrayBuffersManager.ts
@@ -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 }[] = []
diff --git a/administration/src/application/useLocallyStoredState.ts b/administration/src/application/useLocallyStoredState.ts
deleted file mode 100644
index 4d2877644..000000000
--- a/administration/src/application/useLocallyStoredState.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import localforage from 'localforage'
-import { useCallback, useEffect, useRef, useState } from 'react'
-import { SetState } from './useUpdateStateCallback'
-
-function useLocallyStoredState(initialState: T, storageKey: string): [T | null, SetState] {
- const [state, setState] = useState(initialState)
- const stateRef = useRef(state)
- const [loading, setLoading] = useState(true)
-
- const setStateAndRef = useCallback((update: (oldState: T) => T) => {
- setState(state => {
- const newState = update(state)
- stateRef.current = newState
- return newState
- })
- }, [])
-
- useEffect(() => {
- localforage
- .getItem(storageKey)
- .then(storedValue => {
- if (storedValue !== null) {
- setStateAndRef(() => storedValue)
- }
- })
- .finally(() => setLoading(false))
- }, [storageKey, setStateAndRef])
-
- useEffect(() => {
- // Auto-save every 2 seconds unless we're still loading the state.
- if (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)
- }, [loading, storageKey])
-
- return [loading ? null : state, setStateAndRef]
-}
-
-export default useLocallyStoredState
diff --git a/administration/src/application/useVersionedLocallyStoredState.ts b/administration/src/application/useVersionedLocallyStoredState.ts
new file mode 100644
index 000000000..418f2dd28
--- /dev/null
+++ b/administration/src/application/useVersionedLocallyStoredState.ts
@@ -0,0 +1,106 @@
+import localforage from 'localforage'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { SetState, useUpdateStateCallback } from './useUpdateStateCallback'
+
+function useLocallyStoredState(
+ initialState: T,
+ storageKey: string
+): {
+ status: 'loading' | 'ready'
+ state: T
+ setState: SetState
+} {
+ const [state, setState] = useState(initialState)
+ const stateRef = useRef(state)
+ const [status, setStatus] = useState<'loading' | 'ready'>('loading')
+
+ useEffect(() => {
+ localforage
+ .getItem(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(
+ initialState: T,
+ storageKey: string,
+ version: string
+): {
+ status: 'loading' | 'ready'
+ state: T
+ setState: SetState
+} {
+ 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 = useUpdateStateCallback(setLocallyStoredState, 'value')
+ if (locallyStoredStatus === 'loading' || locallyStoredVersion !== version)
+ return {
+ status: 'loading',
+ state: initialState,
+ setState,
+ }
+ return { status: 'ready', state: locallyStoredState.value, setState }
+}
+
+export default useVersionedLocallyStoredState