From 116d6e15342b43cfa73a9f7b3ffc2094da61cc45 Mon Sep 17 00:00:00 2001 From: Michael Markl Date: Sun, 20 Nov 2022 18:44:33 +0100 Subject: [PATCH 1/7] Rename useLocallyStoredState.ts to useVersionedLocallyStoredState.ts --- ...useLocallyStoredState.ts => useVersionedLocallyStoredState.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename administration/src/application/{useLocallyStoredState.ts => useVersionedLocallyStoredState.ts} (100%) diff --git a/administration/src/application/useLocallyStoredState.ts b/administration/src/application/useVersionedLocallyStoredState.ts similarity index 100% rename from administration/src/application/useLocallyStoredState.ts rename to administration/src/application/useVersionedLocallyStoredState.ts From 099cfa30b7ac70b0fae297f15ebf1635671390bd Mon Sep 17 00:00:00 2001 From: Michael Markl Date: Sun, 20 Nov 2022 18:45:11 +0100 Subject: [PATCH 2/7] Reset form on change --- administration/package.json | 4 +- .../application/ApplicationErrorBoundary.tsx | 35 +++++++ .../components/ApplyController.tsx | 23 +++-- .../application/globalArrayBuffersManager.ts | 2 +- .../useVersionedLocallyStoredState.ts | 97 +++++++++++++++---- 5 files changed, 131 insertions(+), 30 deletions(-) create mode 100644 administration/src/application/ApplicationErrorBoundary.tsx diff --git a/administration/package.json b/administration/package.json index 6e1365323..c4729ef34 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": "REACT_APP_APPLICATION_COMMIT=\"$(git log -n 1 --format='%h' -- src/application)\" react-scripts start", + "build": "REACT_APP_APPLICATION_COMMIT=\"$(git log -n 1 --format='%h' -- src/application)\" 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..a448c8757 100644 --- a/administration/src/application/components/ApplyController.tsx +++ b/administration/src/application/components/ApplyController.tsx @@ -5,30 +5,35 @@ 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' +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 +69,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/useVersionedLocallyStoredState.ts b/administration/src/application/useVersionedLocallyStoredState.ts index 4d2877644..df0702a53 100644 --- a/administration/src/application/useVersionedLocallyStoredState.ts +++ b/administration/src/application/useVersionedLocallyStoredState.ts @@ -1,30 +1,43 @@ import localforage from 'localforage' import { useCallback, useEffect, useRef, useState } from 'react' -import { SetState } from './useUpdateStateCallback' +import { SetState, useUpdateStateCallback } from './useUpdateStateCallback' -function useLocallyStoredState(initialState: T, storageKey: string): [T | null, SetState] { +function useLocallyStoredState( + initialState: T, + storageKey: string +): { + status: 'loading' | 'ready' + state: T + setState: 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) + .getItem(storageKey) + .then(storedValue => { + if (storedValue !== null) { + stateRef.current = storedValue + setState(storedValue) + } + }) + .finally(() => setLoading(false)) + }, [storageKey, setState, stateRef]) + + const setStateAndRef = useCallback( + (update: (oldState: T) => T) => { + if (!loading) { + setState(state => { + const newState = update(state) + stateRef.current = newState + return newState + }) } - }) - .finally(() => setLoading(false)) - }, [storageKey, setStateAndRef]) + }, + [loading] + ) useEffect(() => { // Auto-save every 2 seconds unless we're still loading the state. @@ -41,7 +54,53 @@ function useLocallyStoredState(initialState: T, storageKey: string): [T | nul return () => clearInterval(interval) }, [loading, storageKey]) - return [loading ? null : state, setStateAndRef] + return { status: loading ? 'loading' : 'ready', 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 useLocallyStoredState +export default useVersionedLocallyStoredState From 0b4fc41094ad956d055a5178fdbb19bab96d9001 Mon Sep 17 00:00:00 2001 From: Michael Markl Date: Sun, 20 Nov 2022 18:55:00 +0100 Subject: [PATCH 3/7] Use status instead of bool --- .../useVersionedLocallyStoredState.ts | 62 +++++++++---------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/administration/src/application/useVersionedLocallyStoredState.ts b/administration/src/application/useVersionedLocallyStoredState.ts index df0702a53..418f2dd28 100644 --- a/administration/src/application/useVersionedLocallyStoredState.ts +++ b/administration/src/application/useVersionedLocallyStoredState.ts @@ -3,8 +3,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { SetState, useUpdateStateCallback } from './useUpdateStateCallback' function useLocallyStoredState( - initialState: T, - storageKey: string + initialState: T, + storageKey: string ): { status: 'loading' | 'ready' state: T @@ -12,36 +12,36 @@ function useLocallyStoredState( } { const [state, setState] = useState(initialState) const stateRef = useRef(state) - const [loading, setLoading] = useState(true) + const [status, setStatus] = useState<'loading' | 'ready'>('loading') useEffect(() => { localforage - .getItem(storageKey) - .then(storedValue => { - if (storedValue !== null) { - stateRef.current = storedValue - setState(storedValue) - } - }) - .finally(() => setLoading(false)) + .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 (!loading) { - setState(state => { - const newState = update(state) - stateRef.current = newState - return newState - }) - } - }, - [loading] + (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 (loading) { + if (status === 'loading') { return } let lastState: T | null = null @@ -52,9 +52,9 @@ function useLocallyStoredState( } }, 2000) return () => clearInterval(interval) - }, [loading, storageKey]) + }, [status, storageKey]) - return { status: loading ? 'loading' : 'ready', state, setState: setStateAndRef } + return { status, state, setState: setStateAndRef } } /** @@ -66,9 +66,9 @@ function useLocallyStoredState( * While localforage is loading, the returned status is set to `loading` otherwise to `ready`. */ function useVersionedLocallyStoredState( - initialState: T, - storageKey: string, - version: string + initialState: T, + storageKey: string, + version: string ): { status: 'loading' | 'ready' state: T @@ -80,13 +80,13 @@ function useVersionedLocallyStoredState( state: locallyStoredState, } = useLocallyStoredState({ version, value: initialState }, storageKey) const locallyStoredVersion = - typeof locallyStoredState === 'object' && 'version' in locallyStoredState - ? locallyStoredState.version - : '(could not determine)' + 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` + + `Resetting storage because of version mismatch: \n` + `Locally stored version: ${locallyStoredVersion}.\n` + `New version: ${version}.` ) From 868acf9d18621c2043f294cc32dc9a911e897573 Mon Sep 17 00:00:00 2001 From: Michael Markl Date: Mon, 21 Nov 2022 22:48:02 +0100 Subject: [PATCH 4/7] Add application_commit.sh --- administration/application_commit.sh | 11 +++++++++++ administration/package.json | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100755 administration/application_commit.sh diff --git a/administration/application_commit.sh b/administration/application_commit.sh new file mode 100755 index 000000000..228481182 --- /dev/null +++ b/administration/application_commit.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +basedir=$(dirname "$0") +commit=$(git log -n 1 --format="%h" -- "${basedir}/src/applicastion") +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 c4729ef34..2f7847daf 100644 --- a/administration/package.json +++ b/administration/package.json @@ -54,8 +54,8 @@ "typescript": "^4.8.3" }, "scripts": { - "start": "REACT_APP_APPLICATION_COMMIT=\"$(git log -n 1 --format='%h' -- src/application)\" react-scripts start", - "build": "REACT_APP_APPLICATION_COMMIT=\"$(git log -n 1 --format='%h' -- src/application)\" react-scripts build", + "start": "set -e; REACT_APP_APPLICATION_COMMIT=$(./application_commit.sh); react-scripts start", + "build": "set -e; REACT_APP_APPLICATION_COMMIT=$(./application_commit.sh); react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", "lint": "eslint . && prettier . --check", From 5166ebe34984f16c732c2aadefbcc93d2dd2a186 Mon Sep 17 00:00:00 2001 From: Michael Markl Date: Mon, 21 Nov 2022 22:50:23 +0100 Subject: [PATCH 5/7] Add comment --- administration/application_commit.sh | 6 +++++- administration/package.json | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/administration/application_commit.sh b/administration/application_commit.sh index 228481182..2d87722af 100755 --- a/administration/application_commit.sh +++ b/administration/application_commit.sh @@ -1,7 +1,11 @@ #!/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. + basedir=$(dirname "$0") -commit=$(git log -n 1 --format="%h" -- "${basedir}/src/applicastion") +commit=$(git log -n 1 --format="%h" -- "${basedir}/src/application") if [ -z "$commit" ] then >&2 echo "Could not determine last application commit!" diff --git a/administration/package.json b/administration/package.json index 2f7847daf..1c9f7dc4b 100644 --- a/administration/package.json +++ b/administration/package.json @@ -54,8 +54,8 @@ "typescript": "^4.8.3" }, "scripts": { - "start": "set -e; REACT_APP_APPLICATION_COMMIT=$(./application_commit.sh); react-scripts start", - "build": "set -e; REACT_APP_APPLICATION_COMMIT=$(./application_commit.sh); 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", From b9d24ddb3d945a411f04430b775ecdd8ef20cadd Mon Sep 17 00:00:00 2001 From: Michael Markl Date: Mon, 5 Dec 2022 11:56:03 +0100 Subject: [PATCH 6/7] Add check for git --- administration/application_commit.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/administration/application_commit.sh b/administration/application_commit.sh index 2d87722af..8183bfd39 100755 --- a/administration/application_commit.sh +++ b/administration/application_commit.sh @@ -4,6 +4,12 @@ # 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" ] From 14a1c55318ef837eb4d9e47df261d5523857eaa8 Mon Sep 17 00:00:00 2001 From: Michael Markl Date: Mon, 5 Dec 2022 11:56:13 +0100 Subject: [PATCH 7/7] Add comment --- administration/src/application/components/ApplyController.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/administration/src/application/components/ApplyController.tsx b/administration/src/application/components/ApplyController.tsx index a448c8757..d6c0ac3d9 100644 --- a/administration/src/application/components/ApplyController.tsx +++ b/administration/src/application/components/ApplyController.tsx @@ -13,7 +13,10 @@ import { useCallback, useMemo } from 'react' import { SnackbarProvider, useSnackbar } from 'notistack' import ApplicationErrorBoundary from '../ApplicationErrorBoundary' +// 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 = () => {