settings: convert flashCurrentProgram to react hook

This commit is contained in:
David Lechner
2022-03-15 16:54:49 -05:00
parent d18ad41e39
commit 861ea34d86
13 changed files with 100 additions and 217 deletions
+16
View File
@@ -39,6 +39,22 @@ describe('showDocs setting switch', () => {
});
});
describe('flashCurrentProgram setting switch', () => {
it('should toggle the setting', () => {
const [settings] = testRender(
<SettingsDrawer isOpen={true} onClose={() => undefined} />,
);
expect(localStorage.getItem('setting.flashCurrentProgram')).toBe(null);
settings.getByLabelText('Include current program').click();
expect(localStorage.getItem('setting.flashCurrentProgram')).toBe('true');
settings.getByLabelText('Include current program').click();
expect(localStorage.getItem('setting.flashCurrentProgram')).toBe('false');
});
});
describe('about dialog', () => {
it('should open the dialog when the button is clicked', async () => {
const [settings] = testRender(
+8 -10
View File
@@ -35,9 +35,9 @@ import { pseudolocalize } from '../i18n';
import { useSelector } from '../reducers';
import ExternalLinkIcon from '../utils/ExternalLinkIcon';
import { isMacOS } from '../utils/os';
import { setBoolean, setString } from './actions';
import { BooleanSettingId, StringSettingId } from './defaults';
import { useSettingIsShowDocsEnabled } from './hooks';
import { setString } from './actions';
import { StringSettingId } from './defaults';
import { useSettingFlashCurrentProgram, useSettingIsShowDocsEnabled } from './hooks';
import { SettingsStringId } from './i18n';
import en from './i18n.en.json';
import './settings.scss';
@@ -59,7 +59,8 @@ const SettingsDrawer: React.VoidFunctionComponent<SettingsProps> = ({
const [isAboutDialogOpen, setIsAboutDialogOpen] = useState(false);
const { isDarkMode, toggle: toggleDarkMode } = useDarkMode();
const flashCurrentProgram = useSelector((s) => s.settings.flashCurrentProgram);
const [isFlashCurrentProgramEnabled, setIsFlashCurrentProgramEnabled] =
useSettingFlashCurrentProgram();
const isServiceWorkerRegistered = useSelector(
(s) => s.app.isServiceWorkerRegistered,
);
@@ -169,13 +170,10 @@ const SettingsDrawer: React.VoidFunctionComponent<SettingsProps> = ({
label={i18n.translate(
SettingsStringId.FirmwareCurrentProgramLabel,
)}
checked={flashCurrentProgram}
checked={isFlashCurrentProgramEnabled}
onChange={(e) =>
dispatch(
setBoolean(
BooleanSettingId.FlashCurrentProgram,
(e.target as HTMLInputElement).checked,
),
setIsFlashCurrentProgramEnabled(
(e.target as HTMLInputElement).checked,
)
}
/>
+1 -30
View File
@@ -2,36 +2,7 @@
// Copyright (c) 2021-2022 The Pybricks Authors
import { createAction } from '../actions';
import { BooleanSettingId, StringSettingId } from './defaults';
/** Creates an action to set/store a setting. */
export const setBoolean = createAction((id: BooleanSettingId, newState: boolean) => ({
type: 'settings.action.setBoolean',
id,
newState,
}));
/** Creates an action to toggle a setting. */
export const toggleBoolean = createAction((id: BooleanSettingId) => ({
type: 'settings.action.toggleBoolean',
id,
}));
/** Creates an action indicating that setting/storing a setting failed. */
export const didFailToSetBoolean = createAction((id: BooleanSettingId, err: Error) => ({
type: 'settings.action.didFailToSetBoolean',
id,
err,
}));
/** Creates an action that indicates a stored boolean setting value changed. */
export const didBooleanChange = createAction(
(id: BooleanSettingId, newState: boolean) => ({
type: 'settings.action.didBooleanChange',
id,
newState,
}),
);
import { StringSettingId } from './defaults';
/** Creates an action to set/store a setting. */
export const setString = createAction((id: StringSettingId, newState: string) => ({
-14
View File
@@ -3,20 +3,6 @@
// Definitions for user selectable settings.
export enum BooleanSettingId {
FlashCurrentProgram = 'flashCurrentProgram',
}
export function getDefaultBooleanValue(id: BooleanSettingId): boolean {
switch (id) {
case BooleanSettingId.FlashCurrentProgram:
return false;
// istanbul ignore next: it is a programmer error if we hit this
default:
throw Error(`Bad BooleanSettingId: ${id}`);
}
}
export enum StringSettingId {
HubName = 'hubName',
}
+9 -1
View File
@@ -1,9 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { useCallback } from 'react';
import { Dispatch, SetStateAction, useCallback } from 'react';
import { useLocalStorage } from 'usehooks-ts';
// this is private type from usehooks-ts
type SetValue<T> = Dispatch<SetStateAction<T>>;
/** Hook for "showDocs" setting. */
export function useSettingIsShowDocsEnabled(): {
isSettingShowDocsEnabled: boolean;
@@ -26,3 +29,8 @@ export function useSettingIsShowDocsEnabled(): {
toggleIsSettingShowDocsEnabled,
};
}
/** Hook for "flashCurrentProgram" setting. */
export function useSettingFlashCurrentProgram(): [boolean, SetValue<boolean>] {
return useLocalStorage<boolean>('setting.flashCurrentProgram', false);
}
+2 -14
View File
@@ -2,8 +2,8 @@
// Copyright (c) 2021-2022 The Pybricks Authors
import { AnyAction } from 'redux';
import { didBooleanChange, didStringChange } from './actions';
import { BooleanSettingId, StringSettingId } from './defaults';
import { didStringChange } from './actions';
import { StringSettingId } from './defaults';
import reducers from './reducers';
type State = ReturnType<typeof reducers>;
@@ -11,24 +11,12 @@ type State = ReturnType<typeof reducers>;
test('initial state', () => {
expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(`
Object {
"flashCurrentProgram": false,
"hubName": "",
"isHubNameValid": true,
}
`);
});
describe('flashCurrentProgram', () => {
test('setting changed', () => {
expect(
reducers(
{ flashCurrentProgram: false } as State,
didBooleanChange(BooleanSettingId.FlashCurrentProgram, true),
).flashCurrentProgram,
).toBe(true);
});
});
describe('hubName', () => {
const testName = 'test name';
test('setting changed', () => {
+2 -22
View File
@@ -2,30 +2,11 @@
// Copyright (c) 2021-2022 The Pybricks Authors
import { Reducer, combineReducers } from 'redux';
import { didBooleanChange, didStringChange } from './actions';
import {
BooleanSettingId,
StringSettingId,
getDefaultBooleanValue,
getDefaultStringValue,
} from './defaults';
import { didStringChange } from './actions';
import { StringSettingId, getDefaultStringValue } from './defaults';
const encoder = new TextEncoder();
const flashCurrentProgram: Reducer<boolean> = (
state = getDefaultBooleanValue(BooleanSettingId.FlashCurrentProgram),
action,
) => {
if (didBooleanChange.matches(action)) {
if (action.id === BooleanSettingId.FlashCurrentProgram) {
return action.newState;
}
return state;
}
return state;
};
const hubName: Reducer<string> = (
state = getDefaultStringValue(StringSettingId.HubName),
action,
@@ -59,7 +40,6 @@ const isHubNameValid: Reducer<boolean> = (state = true, action) => {
};
export default combineReducers({
flashCurrentProgram,
hubName,
isHubNameValid,
});
+1 -26
View File
@@ -5,14 +5,12 @@
import { AsyncSaga } from '../../test';
import {
didBooleanChange,
didFailToSetString,
didStringChange,
setBoolean,
setString,
settingsToggleShowDocs,
} from './actions';
import { BooleanSettingId, StringSettingId } from './defaults';
import { StringSettingId } from './defaults';
import settings from './sagas';
afterEach(() => {
@@ -44,29 +42,6 @@ describe('store settings to local storage', () => {
await saga.end();
});
test('flashCurrentProgram', async () => {
const saga = new AsyncSaga(settings);
saga.updateState({ settings: { flashCurrentProgram: true } });
const mockSetItem = jest
.spyOn(Object.getPrototypeOf(window.localStorage), 'setItem')
.mockImplementation((key, value) => {
expect(key).toBe('setting.flashCurrentProgram');
expect(value).toBe('false');
});
saga.put(setBoolean(BooleanSettingId.FlashCurrentProgram, false));
expect(mockSetItem).toHaveBeenCalled();
const action = await saga.take();
expect(action).toEqual(
didBooleanChange(BooleanSettingId.FlashCurrentProgram, false),
);
await saga.end();
});
});
describe('storage monitor', () => {
+1 -68
View File
@@ -11,25 +11,12 @@ import { didStart } from '../app/actions';
import { RootState } from '../reducers';
import { ensureError } from '../utils';
import {
didBooleanChange,
didFailToSetBoolean,
didFailToSetString,
didStringChange,
setBoolean,
setString,
settingsToggleShowDocs,
toggleBoolean,
} from './actions';
import {
BooleanSettingId,
StringSettingId,
getDefaultBooleanValue,
getDefaultStringValue,
} from './defaults';
function stringToBoolean(value: string): boolean {
return value.toLowerCase().match(/(true|yes|1)/) !== null;
}
import { StringSettingId, getDefaultStringValue } from './defaults';
function createLocalStorageEventChannel(): EventChannel<StorageEvent> {
return eventChannel((emitter) => {
@@ -58,16 +45,6 @@ function* monitorLocalStorage(): Generator {
const id = event.key.replace(/^setting\./, '');
if (Object.values(BooleanSettingId).includes(id as BooleanSettingId)) {
yield* put(
didBooleanChange(
id as BooleanSettingId,
stringToBoolean(event.newValue || 'false'),
),
);
continue;
}
if (Object.values(StringSettingId).includes(id as StringSettingId)) {
yield* put(didStringChange(id as StringSettingId, event.newValue || ''));
continue;
@@ -79,17 +56,6 @@ function* monitorLocalStorage(): Generator {
}
function* loadSettings(): Generator {
for (const id of Object.values(BooleanSettingId)) {
const storageValue = localStorage.getItem(`setting.${id}`);
const defaultValue = getDefaultBooleanValue(id);
const value =
storageValue === null ? defaultValue : stringToBoolean(storageValue);
if (value !== defaultValue) {
yield* put(didBooleanChange(id, value));
}
}
for (const id of Object.values(StringSettingId)) {
const storageValue = localStorage.getItem(`setting.${id}`);
const defaultValue = getDefaultStringValue(id);
@@ -101,37 +67,6 @@ function* loadSettings(): Generator {
}
}
function* storeBooleanSetting(action: ReturnType<typeof setBoolean>): Generator {
const key = `setting.${action.id}`;
const newValue = String(action.newState);
try {
localStorage.setItem(key, newValue);
} catch (err) {
yield* put(didFailToSetBoolean(action.id, ensureError(err)));
}
// storage event is only raised when a value is changed externally, so we
// mimic the event when we call setItem(), whether it actually succeeded
// or not.
const oldState = yield* select((s: RootState) => s.settings[action.id]);
if (action.newState !== oldState) {
window.dispatchEvent(
new StorageEvent('storage', {
key,
newValue,
oldValue: String(oldState),
storageArea: localStorage,
}),
);
}
}
function* toggleBooleanSetting(action: ReturnType<typeof toggleBoolean>): Generator {
const oldValue = yield* select((s: RootState) => s.settings[action.id]);
yield* storeBooleanSetting(setBoolean(action.id, !oldValue));
}
function* storeStringSetting(action: ReturnType<typeof setString>): Generator {
const key = `setting.${action.id}`;
const newValue = action.newState;
@@ -180,8 +115,6 @@ function* handleToggleShowDocs(): Generator {
export default function* (): Generator {
yield* fork(monitorLocalStorage);
yield* takeEvery(didStart, loadSettings);
yield* takeEvery(setBoolean, storeBooleanSetting);
yield* takeEvery(toggleBoolean, toggleBooleanSetting);
yield* takeEvery(setString, storeStringSetting);
yield* takeEvery(settingsToggleShowDocs, handleToggleShowDocs);
}