Skip to content

Commit

Permalink
Extract local storage management to hook (#840)
Browse files Browse the repository at this point in the history
  • Loading branch information
chriswilty committed Apr 8, 2024
1 parent 7b0a934 commit e875e44
Show file tree
Hide file tree
Showing 5 changed files with 217 additions and 76 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/frontend-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 15 additions & 74 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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';
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';

Expand All @@ -16,71 +17,26 @@ function App() {
const dialogRef = useRef<HTMLDialogElement>(null);
const contentRef = useRef<HTMLDivElement>(null);

const [isNewUser, setIsNewUser] = useState(loadIsNewUser);
const [currentLevel, setCurrentLevel] =
useState<LEVEL_NAMES>(loadCurrentLevel);
const [numCompletedLevels, setNumCompletedLevels] = useState(
loadNumCompletedLevels
);
const {
isNewUser,
setIsNewUser,
currentLevel,
setCurrentLevel,
numCompletedLevels,
setCompletedLevels,
resetCompletedLevels,
} = useLocalStorage();

const [overlayComponent, setOverlayComponent] = useState<JSX.Element | null>(
null
);

const [mainComponentKey, setMainComponentKey] = useState<number>(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);
Expand All @@ -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(() => {
Expand Down Expand Up @@ -172,12 +120,7 @@ function App() {
}
function openLevelsCompleteOverlay() {
openOverlay(
<LevelsComplete
goToSandbox={() => {
goToSandbox();
}}
closeOverlay={closeOverlay}
/>
<LevelsComplete goToSandbox={goToSandbox} closeOverlay={closeOverlay} />
);
}
function openDocumentViewer() {
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/MainComponent/MainComponent.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
116 changes: 116 additions & 0 deletions frontend/src/hooks/useLocalStorage.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
84 changes: 84 additions & 0 deletions frontend/src/hooks/useLocalStorage.ts
Original file line number Diff line number Diff line change
@@ -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<LEVEL_NAMES>(
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;
}
}

0 comments on commit e875e44

Please sign in to comment.