Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract local storage management to hook #840

Merged
merged 3 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
}
Loading