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