diff --git a/.github/workflows/frontend-checks.yml b/.github/workflows/frontend-checks.yml index 84b91e707..7d40660c2 100644 --- a/.github/workflows/frontend-checks.yml +++ b/.github/workflows/frontend-checks.yml @@ -55,7 +55,7 @@ jobs: node-version: ${{ matrix.node-version }} cache: "npm" cache-dependency-path: "./frontend/package-lock.json" - - name: Run nob + - name: Run job run: | npm ci npm run build diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1b1ef56ed..1f710a856 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, JSX } from 'react'; import DocumentViewBox from './components/DocumentViewer/DocumentViewBox'; import MainComponent from './components/MainComponent/MainComponent'; @@ -6,6 +6,7 @@ import LevelsComplete from './components/Overlay/LevelsComplete'; import MissionInformation from './components/Overlay/MissionInformation'; import OverlayWelcome from './components/Overlay/OverlayWelcome'; import ResetProgressOverlay from './components/Overlay/ResetProgress'; +import useLocalStorage from './hooks/useLocalStorage'; import { LEVEL_NAMES } from './models/level'; import { levelService } from './service'; @@ -16,12 +17,15 @@ function App() { const dialogRef = useRef(null); const contentRef = useRef(null); - const [isNewUser, setIsNewUser] = useState(loadIsNewUser); - const [currentLevel, setCurrentLevel] = - useState(loadCurrentLevel); - const [numCompletedLevels, setNumCompletedLevels] = useState( - loadNumCompletedLevels - ); + const { + isNewUser, + setIsNewUser, + currentLevel, + setCurrentLevel, + numCompletedLevels, + setCompletedLevels, + resetCompletedLevels, + } = useLocalStorage(); const [overlayComponent, setOverlayComponent] = useState( null @@ -29,58 +33,10 @@ function App() { const [mainComponentKey, setMainComponentKey] = useState(0); - function loadIsNewUser() { - // get isNewUser from local storage - const isNewUserStr = localStorage.getItem('isNewUser'); - if (isNewUserStr) { - return isNewUserStr === 'true'; - } else { - // is new user by default - return true; - } - } - - function loadCurrentLevel() { - // get current level from local storage - const currentLevelStr = localStorage.getItem('currentLevel'); - if (currentLevelStr && !isNewUser) { - // start the user from where they last left off - const level = parseInt(currentLevelStr); - if (level < LEVEL_NAMES.LEVEL_1 || level > LEVEL_NAMES.SANDBOX) { - console.error( - `Invalid level ${level} in local storage, defaulting to level 1` - ); - return LEVEL_NAMES.LEVEL_1; - } - return parseInt(currentLevelStr) as LEVEL_NAMES; - } else { - // by default, start on level 1 - return LEVEL_NAMES.LEVEL_1; - } - } - - function loadNumCompletedLevels() { - // get number of completed levels from local storage - const numCompletedLevelsStr = localStorage.getItem('numCompletedLevels'); - - if (numCompletedLevelsStr && !isNewUser) { - // keep users progress from where they last left off - return parseInt(numCompletedLevelsStr); - } else { - // 0 levels completed by default - return 0; - } - } - function updateNumCompletedLevels(completedLevel: LEVEL_NAMES) { - setNumCompletedLevels(Math.max(numCompletedLevels, completedLevel + 1)); + setCompletedLevels(completedLevel + 1); } - useEffect(() => { - // save number of completed levels to local storage - localStorage.setItem('numCompletedLevels', numCompletedLevels.toString()); - }, [numCompletedLevels]); - // called on mount useEffect(() => { window.addEventListener('keydown', handleEscape); @@ -90,19 +46,11 @@ function App() { }, []); useEffect(() => { - // save current level to local storage - localStorage.setItem('currentLevel', currentLevel.toString()); - // show the information for the new level openInformationOverlay(); }, [currentLevel]); useEffect(() => { - // save isNewUser to local storage - localStorage.setItem('isNewUser', isNewUser.toString()); - // open the welcome overlay for a new user - if (isNewUser) { - openWelcomeOverlay(); - } + if (isNewUser) openWelcomeOverlay(); }, [isNewUser]); useEffect(() => { @@ -172,12 +120,7 @@ function App() { } function openLevelsCompleteOverlay() { openOverlay( - { - goToSandbox(); - }} - closeOverlay={closeOverlay} - /> + ); } function openDocumentViewer() { @@ -213,9 +156,7 @@ function App() { // reset on the backend await levelService.resetAllLevelProgress(); - - localStorage.setItem('numCompletedLevels', '0'); - setNumCompletedLevels(0); + resetCompletedLevels(); // set as new user so welcome modal shows setIsNewUser(true); diff --git a/frontend/src/components/MainComponent/MainComponent.tsx b/frontend/src/components/MainComponent/MainComponent.tsx index 4be21c8b7..d5259152e 100644 --- a/frontend/src/components/MainComponent/MainComponent.tsx +++ b/frontend/src/components/MainComponent/MainComponent.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, JSX } from 'react'; import { DEFAULT_DEFENCES } from '@src/Defences'; import HandbookOverlay from '@src/components/HandbookOverlay/HandbookOverlay'; diff --git a/frontend/src/hooks/useLocalStorage.test.ts b/frontend/src/hooks/useLocalStorage.test.ts new file mode 100644 index 000000000..a4332e21f --- /dev/null +++ b/frontend/src/hooks/useLocalStorage.test.ts @@ -0,0 +1,116 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, test } from 'vitest'; + +import { LEVEL_NAMES } from '@src/models/level'; + +import useLocalStorage from './useLocalStorage'; + +const newUserKey = 'isNewUser'; +const currentLevelKey = 'currentLevel'; +const completedLevelsKey = 'numCompletedLevels'; + +describe('useLocalStorage hook', () => { + afterEach(() => { + localStorage.clear(); + }); + + test.each([true, false])( + `Reads ${newUserKey} from localStorage on init`, + (isNewUser) => { + localStorage.setItem(newUserKey, `${isNewUser}`); + + const { result } = renderHook(useLocalStorage); + expect(result.current.isNewUser).toBe(isNewUser); + } + ); + + test(`Default to ${newUserKey}=true when unset`, () => { + const { result } = renderHook(useLocalStorage); + expect(result.current.isNewUser).toBe(true); + }); + + test(`Reads ${currentLevelKey} from localStorage on init, if not new user`, () => { + localStorage.setItem(newUserKey, 'false'); + localStorage.setItem(currentLevelKey, LEVEL_NAMES.LEVEL_3.toString()); + + const { result } = renderHook(useLocalStorage); + expect(result.current.currentLevel).toBe(LEVEL_NAMES.LEVEL_3); + }); + + test(`Ignores ${currentLevelKey} in localStorage on init, if new user`, () => { + localStorage.setItem(newUserKey, 'true'); + localStorage.setItem(currentLevelKey, LEVEL_NAMES.LEVEL_3.toString()); + + const { result } = renderHook(useLocalStorage); + expect(result.current.currentLevel).toBe(LEVEL_NAMES.LEVEL_1); + }); + + test(`Reads ${completedLevelsKey} from localStorage on init, if not new user`, () => { + localStorage.setItem(newUserKey, 'false'); + localStorage.setItem(completedLevelsKey, '2'); + + const { result } = renderHook(useLocalStorage); + expect(result.current.numCompletedLevels).toBe(2); + }); + + test(`Ignores ${completedLevelsKey} in localStorage on init, if new user`, () => { + localStorage.setItem(newUserKey, 'true'); + localStorage.setItem(completedLevelsKey, '2'); + + const { result } = renderHook(useLocalStorage); + expect(result.current.currentLevel).toBe(0); + }); + + test(`Persists ${newUserKey} in storage when set`, () => { + const { result } = renderHook(useLocalStorage); + expect(result.current.isNewUser).toBe(true); + + act(() => { + result.current.setIsNewUser(false); + }); + + expect(result.current.isNewUser).toBe(false); + expect(localStorage.getItem(newUserKey)).toEqual('false'); + }); + + test(`Persists ${currentLevelKey} in storage when set`, () => { + const { result } = renderHook(useLocalStorage); + expect(result.current.currentLevel).toBe(LEVEL_NAMES.LEVEL_1); + + act(() => { + result.current.setCurrentLevel(LEVEL_NAMES.LEVEL_3); + }); + + expect(result.current.currentLevel).toBe(LEVEL_NAMES.LEVEL_3); + expect(localStorage.getItem(currentLevelKey)).toEqual( + `${LEVEL_NAMES.LEVEL_3}` + ); + }); + + test(`Persists ${completedLevelsKey} in storage when set`, () => { + const { result } = renderHook(useLocalStorage); + expect(result.current.numCompletedLevels).toBe(0); + + act(() => { + result.current.setCompletedLevels(2); + }); + + expect(result.current.numCompletedLevels).toBe(2); + expect(localStorage.getItem(completedLevelsKey)).toEqual('2'); + }); + + test(`${completedLevelsKey} is unchanged when setting it lower than current`, () => { + localStorage.setItem(newUserKey, 'false'); + localStorage.setItem(completedLevelsKey, '3'); + + const { result } = renderHook(useLocalStorage); + expect(result.current.numCompletedLevels).toBe(3); + + act(() => { + result.current.setCompletedLevels(2); + }); + + expect(result.current.numCompletedLevels).toBe(3); + expect(localStorage.getItem(completedLevelsKey)).toEqual('3'); + }); +}); diff --git a/frontend/src/hooks/useLocalStorage.ts b/frontend/src/hooks/useLocalStorage.ts new file mode 100644 index 000000000..80cd32768 --- /dev/null +++ b/frontend/src/hooks/useLocalStorage.ts @@ -0,0 +1,84 @@ +import { useCallback, useState } from 'react'; + +import { LEVEL_NAMES } from '@src/models/level'; + +export default function useLocalStorage() { + const [isNewUser, setNewUser] = useState(loadIsNewUser); + + const setIsNewUser = useCallback((isNew: boolean) => { + setNewUser(isNew); + localStorage.setItem('isNewUser', isNew.toString()); + }, []); + + const [currentLevel, setLevel] = useState( + loadCurrentLevel(isNewUser) + ); + + const setCurrentLevel = useCallback((level: LEVEL_NAMES) => { + setLevel(level); + localStorage.setItem('currentLevel', level.toString()); + }, []); + + const [numCompletedLevels, setNumCompletedLevels] = useState( + loadNumCompletedLevels(isNewUser) + ); + + const setCompletedLevels = useCallback((levels: number) => { + setNumCompletedLevels((prev) => { + const completed = Math.max(prev, levels); + localStorage.setItem('numCompletedLevels', `${completed}`); + return completed; + }); + }, []); + + const resetCompletedLevels = useCallback(() => { + setNumCompletedLevels(0); + localStorage.setItem('numCompletedLevels', '0'); + }, []); + + return { + isNewUser, + setIsNewUser, + currentLevel, + setCurrentLevel, + numCompletedLevels, + setCompletedLevels, + resetCompletedLevels, + }; +} + +function loadIsNewUser() { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return (localStorage.getItem('isNewUser') || 'true') === 'true'; +} + +function loadCurrentLevel(isNewUser: boolean) { + const levelInStorage = localStorage.getItem('currentLevel'); + const level = + levelInStorage && !isNewUser + ? parseInt(levelInStorage) + : LEVEL_NAMES.LEVEL_1; + + if ( + Number.isNaN(level) || + level < LEVEL_NAMES.LEVEL_1 || + level > LEVEL_NAMES.SANDBOX + ) { + console.error( + `Invalid level ${level} in local storage, defaulting to level 1` + ); + return LEVEL_NAMES.LEVEL_1; + } + + return level as LEVEL_NAMES; +} + +function loadNumCompletedLevels(isNewUser: boolean) { + const numCompletedLevelsStr = localStorage.getItem('numCompletedLevels'); + if (numCompletedLevelsStr && !isNewUser) { + // keep user's progress from where they last left off + return parseInt(numCompletedLevelsStr); + } else { + return 0; + } +}