mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
settings: add new hubName setting
This setting will be used to customize the hub name when flashing firmware. This commit just implements the setting. Some changes needed to be made to the existing settings infrastructure since previously it only allowed boolean settings.
This commit is contained in:
+2
-2
@@ -8,7 +8,7 @@ import SplitterLayout from 'react-splitter-layout';
|
||||
import Editor from '../editor/Editor';
|
||||
import { RootState } from '../reducers';
|
||||
import { toggleBoolean } from '../settings/actions';
|
||||
import { SettingId } from '../settings/defaults';
|
||||
import { BooleanSettingId } from '../settings/defaults';
|
||||
import StatusBar from '../status-bar/StatusBar';
|
||||
import Terminal from '../terminal/Terminal';
|
||||
import Toolbar from '../toolbar/Toolbar';
|
||||
@@ -142,7 +142,7 @@ function App(): JSX.Element {
|
||||
e.key == 'd'
|
||||
) {
|
||||
e.preventDefault();
|
||||
dispatch(toggleBoolean(SettingId.ShowDocs));
|
||||
dispatch(toggleBoolean(BooleanSettingId.ShowDocs));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { IDisposable } from 'xterm';
|
||||
import { compile } from '../mpy/actions';
|
||||
import { RootState } from '../reducers';
|
||||
import { toggleBoolean } from '../settings/actions';
|
||||
import { SettingId } from '../settings/defaults';
|
||||
import { BooleanSettingId } from '../settings/defaults';
|
||||
import { IContextMenuTarget, handleContextMenu } from '../utils/IContextMenuTarget';
|
||||
import { isMacOS } from '../utils/os';
|
||||
import { setEditSession, storageChanged } from './actions';
|
||||
@@ -254,7 +254,7 @@ const mapDispatchToProps: DispatchProps = {
|
||||
// REVISIT: the options here might need to be changed - hopefully there is
|
||||
// one setting that works for all hub types for cases where we aren't connected.
|
||||
onCheck: (script) => compile(script, []),
|
||||
onToggleDocs: () => toggleBoolean(SettingId.ShowDocs),
|
||||
onToggleDocs: () => toggleBoolean(BooleanSettingId.ShowDocs),
|
||||
};
|
||||
|
||||
export default connect(
|
||||
|
||||
@@ -93,3 +93,7 @@ a.#{$ns}-button {
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: $pt-icon-color-hover;
|
||||
}
|
||||
|
||||
.#{$ns}-input-group .#{$ns}-icon {
|
||||
margin: 7px;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,16 @@ import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Classes,
|
||||
ControlGroup,
|
||||
Drawer,
|
||||
FormGroup,
|
||||
Hotkey,
|
||||
Hotkeys,
|
||||
HotkeysTarget,
|
||||
Icon,
|
||||
InputGroup,
|
||||
Intent,
|
||||
Label,
|
||||
Position,
|
||||
Switch,
|
||||
Tooltip,
|
||||
@@ -32,10 +37,11 @@ import { RootState } from '../reducers';
|
||||
import ExternalLinkIcon from '../utils/ExternalLinkIcon';
|
||||
import { BeforeInstallPromptEvent } from '../utils/dom';
|
||||
import { isMacOS } from '../utils/os';
|
||||
import { setBoolean, toggleBoolean } from './actions';
|
||||
import { SettingId } from './defaults';
|
||||
import { setBoolean, setString, toggleBoolean } from './actions';
|
||||
import { BooleanSettingId, StringSettingId } from './defaults';
|
||||
import { SettingsStringId } from './i18n';
|
||||
import en from './i18n.en.json';
|
||||
import './settings.scss';
|
||||
|
||||
type StateProps = {
|
||||
showDocs: boolean;
|
||||
@@ -47,6 +53,8 @@ type StateProps = {
|
||||
beforeInstallPrompt: BeforeInstallPromptEvent | null;
|
||||
promptingInstall: boolean;
|
||||
readyForOfflineUse: boolean;
|
||||
hubName: string;
|
||||
isHubNameValid: boolean;
|
||||
};
|
||||
|
||||
type DispatchProps = {
|
||||
@@ -57,6 +65,7 @@ type DispatchProps = {
|
||||
onCheckForUpdate: (registration: ServiceWorkerRegistration) => void;
|
||||
onReload: (registration: ServiceWorkerRegistration) => void;
|
||||
onInstallPrompt: (event: BeforeInstallPromptEvent) => void;
|
||||
onHubNameChange: React.FormEventHandler<HTMLInputElement>;
|
||||
};
|
||||
|
||||
type OwnProps = {
|
||||
@@ -92,6 +101,9 @@ class SettingsDrawer extends React.PureComponent<SettingsProps> {
|
||||
isOpen,
|
||||
onClose,
|
||||
i18n,
|
||||
hubName,
|
||||
isHubNameValid,
|
||||
onHubNameChange,
|
||||
} = this.props;
|
||||
return (
|
||||
<Drawer
|
||||
@@ -180,6 +192,40 @@ class SettingsDrawer extends React.PureComponent<SettingsProps> {
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<ControlGroup>
|
||||
<Label className={Classes.INLINE}>
|
||||
{i18n.translate(
|
||||
SettingsStringId.FirmwareHubNameLabel,
|
||||
)}
|
||||
<InputGroup
|
||||
value={hubName}
|
||||
onChange={onHubNameChange}
|
||||
className="pb-hub-name-input"
|
||||
intent={
|
||||
isHubNameValid ? Intent.NONE : Intent.DANGER
|
||||
}
|
||||
placeholder="Pybricks Hub"
|
||||
rightElement={
|
||||
isHubNameValid ? undefined : (
|
||||
<Tooltip
|
||||
content={i18n.translate(
|
||||
SettingsStringId.FirmwareHubNameErrorTooltip,
|
||||
)}
|
||||
boundary="window"
|
||||
position={Position.BOTTOM}
|
||||
targetTagName="div"
|
||||
>
|
||||
<Icon
|
||||
icon="error"
|
||||
intent={Intent.DANGER}
|
||||
itemType="div"
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
</ControlGroup>
|
||||
</FormGroup>
|
||||
<FormGroup label={i18n.translate(SettingsStringId.HelpTitle)}>
|
||||
<ButtonGroup
|
||||
@@ -328,17 +374,21 @@ const mapStateToProps = (state: RootState): StateProps => ({
|
||||
beforeInstallPrompt: state.app.beforeInstallPrompt,
|
||||
promptingInstall: state.app.promptingInstall,
|
||||
readyForOfflineUse: state.app.readyForOfflineUse,
|
||||
hubName: state.settings.hubName,
|
||||
isHubNameValid: state.settings.isHubNameValid,
|
||||
});
|
||||
|
||||
const mapDispatchToProps: DispatchProps = {
|
||||
onShowDocsChanged: (checked) => setBoolean(SettingId.ShowDocs, checked),
|
||||
onDarkModeChanged: (checked) => setBoolean(SettingId.DarkMode, checked),
|
||||
onShowDocsChanged: (checked) => setBoolean(BooleanSettingId.ShowDocs, checked),
|
||||
onDarkModeChanged: (checked) => setBoolean(BooleanSettingId.DarkMode, checked),
|
||||
onFlashCurrentProgramChanged: (checked) =>
|
||||
setBoolean(SettingId.FlashCurrentProgram, checked),
|
||||
onToggleDocs: () => toggleBoolean(SettingId.ShowDocs),
|
||||
setBoolean(BooleanSettingId.FlashCurrentProgram, checked),
|
||||
onToggleDocs: () => toggleBoolean(BooleanSettingId.ShowDocs),
|
||||
onCheckForUpdate: checkForUpdate,
|
||||
onReload: reload,
|
||||
onInstallPrompt: installPrompt,
|
||||
onHubNameChange: (event) =>
|
||||
setString(StringSettingId.HubName, event.currentTarget.value),
|
||||
};
|
||||
|
||||
export default connect(
|
||||
|
||||
+60
-13
@@ -2,7 +2,7 @@
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
|
||||
import { Action } from 'redux';
|
||||
import { SettingId } from './defaults';
|
||||
import { BooleanSettingId, StringSettingId } from './defaults';
|
||||
|
||||
/** Actions related to settings. */
|
||||
export enum SettingsActionType {
|
||||
@@ -10,44 +10,50 @@ export enum SettingsActionType {
|
||||
ToggleBoolean = 'settings.action.toggleBoolean',
|
||||
DidFailToSetBoolean = 'settings.action.didFailToSetBoolean',
|
||||
DidBooleanChange = 'settings.action.didBooleanChange',
|
||||
SetString = 'settings.action.setString',
|
||||
DidFailToSetString = 'settings.action.didFailToSetString',
|
||||
DidStringChange = 'settings.action.didStringChange',
|
||||
}
|
||||
|
||||
type SettingInfo<T> = {
|
||||
type SettingInfo<TId, TState> = {
|
||||
/** The ID of the setting. */
|
||||
id: SettingId;
|
||||
id: TId;
|
||||
/** The new state for the setting. */
|
||||
newState: T;
|
||||
newState: TState;
|
||||
};
|
||||
|
||||
/** Action to set/store a setting. */
|
||||
export type SettingsSetBooleanAction = Action<SettingsActionType.SetBoolean> &
|
||||
SettingInfo<boolean>;
|
||||
SettingInfo<BooleanSettingId, boolean>;
|
||||
|
||||
/** Creates an action to set/store a setting. */
|
||||
export function setBoolean(id: SettingId, newState: boolean): SettingsSetBooleanAction {
|
||||
export function setBoolean(
|
||||
id: BooleanSettingId,
|
||||
newState: boolean,
|
||||
): SettingsSetBooleanAction {
|
||||
return { type: SettingsActionType.SetBoolean, id, newState };
|
||||
}
|
||||
|
||||
/** Action to toggle a setting. */
|
||||
export type SettingsToggleBooleanAction = Action<SettingsActionType.ToggleBoolean> & {
|
||||
id: SettingId;
|
||||
id: BooleanSettingId;
|
||||
};
|
||||
|
||||
/** Creates an action to toggle a setting. */
|
||||
export function toggleBoolean(id: SettingId): SettingsToggleBooleanAction {
|
||||
export function toggleBoolean(id: BooleanSettingId): SettingsToggleBooleanAction {
|
||||
return { type: SettingsActionType.ToggleBoolean, id };
|
||||
}
|
||||
|
||||
/** Action that indicates setting/storing a setting failed. */
|
||||
export type SettingsDidFailToSetBooleanAction =
|
||||
Action<SettingsActionType.DidFailToSetBoolean> & {
|
||||
id: SettingId;
|
||||
id: BooleanSettingId;
|
||||
err: Error;
|
||||
};
|
||||
|
||||
/** Creates an action indicating that setting/storing a setting failed. */
|
||||
export function didFailToSetBoolean(
|
||||
id: SettingId,
|
||||
id: BooleanSettingId,
|
||||
err: Error,
|
||||
): SettingsDidFailToSetBooleanAction {
|
||||
return { type: SettingsActionType.DidFailToSetBoolean, id, err };
|
||||
@@ -55,19 +61,60 @@ export function didFailToSetBoolean(
|
||||
|
||||
/** Action that indicates a stored boolean setting value changed. */
|
||||
export type SettingsDidBooleanChangeAction =
|
||||
Action<SettingsActionType.DidBooleanChange> & SettingInfo<boolean>;
|
||||
Action<SettingsActionType.DidBooleanChange> &
|
||||
SettingInfo<BooleanSettingId, boolean>;
|
||||
|
||||
/** Creates an action that indicates a stored boolean setting value changed. */
|
||||
export function didBooleanChange(
|
||||
id: SettingId,
|
||||
id: BooleanSettingId,
|
||||
newState: boolean,
|
||||
): SettingsDidBooleanChangeAction {
|
||||
return { type: SettingsActionType.DidBooleanChange, id, newState };
|
||||
}
|
||||
|
||||
/** Action to set/store a setting. */
|
||||
export type SettingsSetStringAction = Action<SettingsActionType.SetString> &
|
||||
SettingInfo<StringSettingId, string>;
|
||||
|
||||
/** Creates an action to set/store a setting. */
|
||||
export function setString(
|
||||
id: StringSettingId,
|
||||
newState: string,
|
||||
): SettingsSetStringAction {
|
||||
return { type: SettingsActionType.SetString, id, newState };
|
||||
}
|
||||
|
||||
/** Action that indicates setting/storing a setting failed. */
|
||||
export type SettingsDidFailToSetStringAction =
|
||||
Action<SettingsActionType.DidFailToSetString> & {
|
||||
id: StringSettingId;
|
||||
err: Error;
|
||||
};
|
||||
|
||||
/** Creates an action indicating that setting/storing a setting failed. */
|
||||
export function didFailToSetString(
|
||||
id: StringSettingId,
|
||||
err: Error,
|
||||
): SettingsDidFailToSetStringAction {
|
||||
return { type: SettingsActionType.DidFailToSetString, id, err };
|
||||
}
|
||||
|
||||
export type SettingsDidStringChangeAction = Action<SettingsActionType.DidStringChange> &
|
||||
SettingInfo<StringSettingId, string>;
|
||||
|
||||
export function didStringChange(
|
||||
id: StringSettingId,
|
||||
newState: string,
|
||||
): SettingsDidStringChangeAction {
|
||||
return { type: SettingsActionType.DidStringChange, id, newState };
|
||||
}
|
||||
|
||||
/** Common type for all settings actions. */
|
||||
export type SettingsAction =
|
||||
| SettingsSetBooleanAction
|
||||
| SettingsToggleBooleanAction
|
||||
| SettingsDidFailToSetBooleanAction
|
||||
| SettingsDidBooleanChangeAction;
|
||||
| SettingsDidBooleanChangeAction
|
||||
| SettingsSetStringAction
|
||||
| SettingsDidFailToSetStringAction
|
||||
| SettingsDidStringChangeAction;
|
||||
|
||||
@@ -5,22 +5,36 @@
|
||||
|
||||
import { prefersDarkMode } from '../utils/os';
|
||||
|
||||
export enum SettingId {
|
||||
export enum BooleanSettingId {
|
||||
ShowDocs = 'showDocs',
|
||||
DarkMode = 'darkMode',
|
||||
FlashCurrentProgram = 'flashCurrentProgram',
|
||||
}
|
||||
|
||||
export function getDefaultBooleanValue(id: SettingId): boolean {
|
||||
export function getDefaultBooleanValue(id: BooleanSettingId): boolean {
|
||||
switch (id) {
|
||||
case SettingId.ShowDocs:
|
||||
case BooleanSettingId.ShowDocs:
|
||||
return window.innerWidth >= 1024;
|
||||
case SettingId.DarkMode:
|
||||
case BooleanSettingId.DarkMode:
|
||||
return prefersDarkMode();
|
||||
case SettingId.FlashCurrentProgram:
|
||||
case BooleanSettingId.FlashCurrentProgram:
|
||||
return false;
|
||||
// istanbul ignore next: it is a programmer error if we hit this
|
||||
default:
|
||||
throw Error(`Bad setting id: ${id}`);
|
||||
throw Error(`Bad BooleanSettingId: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export enum StringSettingId {
|
||||
HubName = 'hubName',
|
||||
}
|
||||
|
||||
export function getDefaultStringValue(id: StringSettingId): string {
|
||||
switch (id) {
|
||||
case StringSettingId.HubName:
|
||||
return ''; // empty string will result in 'Pybricks Hub'
|
||||
// istanbul ignore next: it is a programmer error if we hit this
|
||||
default:
|
||||
throw Error(`Bad StringSettingId: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
"flash-current-program": {
|
||||
"label": "Include current program",
|
||||
"tooltip": "Select to include your program when installing the firmware."
|
||||
},
|
||||
"hub-name": {
|
||||
"label": "Hub name",
|
||||
"error": {
|
||||
"tooltip": "The name is too long."
|
||||
}
|
||||
}
|
||||
},
|
||||
"help": {
|
||||
|
||||
@@ -14,6 +14,8 @@ export enum SettingsStringId {
|
||||
FirmwareTitle = 'settings.firmware.title',
|
||||
FirmwareCurrentProgramLabel = 'settings.firmware.flash-current-program.label',
|
||||
FirmwareCurrentProgramTooltip = 'settings.firmware.flash-current-program.tooltip',
|
||||
FirmwareHubNameLabel = 'settings.firmware.hub-name.label',
|
||||
FirmwareHubNameErrorTooltip = 'settings.firmware.hub-name.error.tooltip',
|
||||
HelpTitle = 'settings.help.title',
|
||||
HelpProjectsLabel = 'settings.help.projects.label',
|
||||
HelpSupportLabel = 'settings.help.support.label',
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Action } from '../actions';
|
||||
import { didBooleanChange, didStringChange } from './actions';
|
||||
import { BooleanSettingId, StringSettingId } from './defaults';
|
||||
import reducers from './reducers';
|
||||
|
||||
type State = ReturnType<typeof reducers>;
|
||||
|
||||
test('initial state', () => {
|
||||
expect(reducers(undefined, {} as Action)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"darkMode": false,
|
||||
"flashCurrentProgram": false,
|
||||
"hubName": "",
|
||||
"isHubNameValid": true,
|
||||
"showDocs": true,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
describe('darkMode', () => {
|
||||
test('setting changed', () => {
|
||||
expect(
|
||||
reducers(
|
||||
{ darkMode: false } as State,
|
||||
didBooleanChange(BooleanSettingId.DarkMode, true),
|
||||
).darkMode,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showDocs', () => {
|
||||
test('setting changed', () => {
|
||||
expect(
|
||||
reducers(
|
||||
{ showDocs: false } as State,
|
||||
didBooleanChange(BooleanSettingId.ShowDocs, true),
|
||||
).showDocs,
|
||||
).toBe(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', () => {
|
||||
expect(
|
||||
reducers(
|
||||
{ hubName: '' } as State,
|
||||
didStringChange(StringSettingId.HubName, testName),
|
||||
).hubName,
|
||||
).toBe(testName);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHubNameValid', () => {
|
||||
test('default name is ok', () => {
|
||||
expect(
|
||||
reducers(
|
||||
undefined,
|
||||
didStringChange(StringSettingId.HubName, 'Pybricks Hub'),
|
||||
).isHubNameValid,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('empty name is ok', () => {
|
||||
expect(
|
||||
reducers(undefined, didStringChange(StringSettingId.HubName, ''))
|
||||
.isHubNameValid,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('too long name fails', () => {
|
||||
expect(
|
||||
reducers(
|
||||
undefined,
|
||||
didStringChange(StringSettingId.HubName, 'this name is way too long'),
|
||||
).isHubNameValid,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('the number of bytes matter, not the number of characters', () => {
|
||||
expect(
|
||||
reducers(
|
||||
undefined,
|
||||
// Chinese characters are 3 bytes each.
|
||||
didStringChange(StringSettingId.HubName, 'Pybricks 枢纽!'),
|
||||
).isHubNameValid,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,15 +4,22 @@
|
||||
import { Reducer, combineReducers } from 'redux';
|
||||
import { Action } from '../actions';
|
||||
import { SettingsActionType } from './actions';
|
||||
import { SettingId, getDefaultBooleanValue } from './defaults';
|
||||
import {
|
||||
BooleanSettingId,
|
||||
StringSettingId,
|
||||
getDefaultBooleanValue,
|
||||
getDefaultStringValue,
|
||||
} from './defaults';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const darkMode: Reducer<boolean, Action> = (
|
||||
state = getDefaultBooleanValue(SettingId.DarkMode),
|
||||
state = getDefaultBooleanValue(BooleanSettingId.DarkMode),
|
||||
action,
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case SettingsActionType.DidBooleanChange:
|
||||
if (action.id === SettingId.DarkMode) {
|
||||
if (action.id === BooleanSettingId.DarkMode) {
|
||||
return action.newState;
|
||||
}
|
||||
return state;
|
||||
@@ -22,12 +29,12 @@ const darkMode: Reducer<boolean, Action> = (
|
||||
};
|
||||
|
||||
const showDocs: Reducer<boolean, Action> = (
|
||||
state = getDefaultBooleanValue(SettingId.ShowDocs),
|
||||
state = getDefaultBooleanValue(BooleanSettingId.ShowDocs),
|
||||
action,
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case SettingsActionType.DidBooleanChange:
|
||||
if (action.id === SettingId.ShowDocs) {
|
||||
if (action.id === BooleanSettingId.ShowDocs) {
|
||||
return action.newState;
|
||||
}
|
||||
return state;
|
||||
@@ -37,12 +44,12 @@ const showDocs: Reducer<boolean, Action> = (
|
||||
};
|
||||
|
||||
const flashCurrentProgram: Reducer<boolean, Action> = (
|
||||
state = getDefaultBooleanValue(SettingId.FlashCurrentProgram),
|
||||
state = getDefaultBooleanValue(BooleanSettingId.FlashCurrentProgram),
|
||||
action,
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case SettingsActionType.DidBooleanChange:
|
||||
if (action.id === SettingId.FlashCurrentProgram) {
|
||||
if (action.id === BooleanSettingId.FlashCurrentProgram) {
|
||||
return action.newState;
|
||||
}
|
||||
return state;
|
||||
@@ -51,4 +58,44 @@ const flashCurrentProgram: Reducer<boolean, Action> = (
|
||||
}
|
||||
};
|
||||
|
||||
export default combineReducers({ darkMode, showDocs, flashCurrentProgram });
|
||||
const hubName: Reducer<string, Action> = (
|
||||
state = getDefaultStringValue(StringSettingId.HubName),
|
||||
action,
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case SettingsActionType.DidStringChange:
|
||||
if (action.id === StringSettingId.HubName) {
|
||||
return action.newState;
|
||||
}
|
||||
return state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const isHubNameValid: Reducer<boolean, Action> = (state = true, action) => {
|
||||
switch (action.type) {
|
||||
case SettingsActionType.DidStringChange:
|
||||
if (action.id === StringSettingId.HubName) {
|
||||
const encoded = encoder.encode(action.newState);
|
||||
|
||||
// Technically, the max hub name size is determined by each individual
|
||||
// firmware file, so we can't check until the firmware has been selected.
|
||||
// However all firmware currently has 16 bytes allocated (including zero-
|
||||
// termination), so we can hard code the check here to allow notifying the
|
||||
// user earlier for better UX.
|
||||
return encoded.length < 16;
|
||||
}
|
||||
return state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default combineReducers({
|
||||
darkMode,
|
||||
showDocs,
|
||||
flashCurrentProgram,
|
||||
hubName,
|
||||
isHubNameValid,
|
||||
});
|
||||
|
||||
+39
-17
@@ -8,10 +8,13 @@ import { didStart } from '../app/actions';
|
||||
import {
|
||||
didBooleanChange,
|
||||
didFailToSetBoolean,
|
||||
didFailToSetString,
|
||||
didStringChange,
|
||||
setBoolean,
|
||||
setString,
|
||||
toggleBoolean,
|
||||
} from './actions';
|
||||
import { SettingId } from './defaults';
|
||||
import { BooleanSettingId, StringSettingId } from './defaults';
|
||||
import settings from './sagas';
|
||||
|
||||
afterAll(() => {
|
||||
@@ -95,7 +98,7 @@ describe('startup', () => {
|
||||
|
||||
// requests documentation to be shown
|
||||
const action = await saga.take();
|
||||
expect(action).toEqual(didBooleanChange(SettingId.ShowDocs, true));
|
||||
expect(action).toEqual(didBooleanChange(BooleanSettingId.ShowDocs, true));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
@@ -120,7 +123,7 @@ describe('startup', () => {
|
||||
|
||||
// requests documentation to be hidden
|
||||
const action = await saga.take();
|
||||
expect(action).toEqual(didBooleanChange(SettingId.ShowDocs, false));
|
||||
expect(action).toEqual(didBooleanChange(BooleanSettingId.ShowDocs, false));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
@@ -184,7 +187,7 @@ describe('startup', () => {
|
||||
|
||||
// requests to enable dark mode
|
||||
const action = await saga.take();
|
||||
expect(action).toEqual(didBooleanChange(SettingId.DarkMode, true));
|
||||
expect(action).toEqual(didBooleanChange(BooleanSettingId.DarkMode, true));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
@@ -215,7 +218,9 @@ describe('startup', () => {
|
||||
|
||||
describe('store settings to local storage', () => {
|
||||
test('failed storage', async () => {
|
||||
const saga = new AsyncSaga(settings, { settings: { showDocs: false } });
|
||||
const saga = new AsyncSaga(settings, {
|
||||
settings: { showDocs: false, hubName: '' },
|
||||
});
|
||||
|
||||
const testError = new Error('local storage is disabled');
|
||||
|
||||
@@ -225,16 +230,31 @@ describe('store settings to local storage', () => {
|
||||
throw testError;
|
||||
});
|
||||
|
||||
saga.put(setBoolean(SettingId.ShowDocs, true));
|
||||
saga.put(setBoolean(BooleanSettingId.ShowDocs, true));
|
||||
expect(mockSetItem).toHaveBeenCalled();
|
||||
|
||||
// raises error that storing setting didn't work
|
||||
const action1 = await saga.take();
|
||||
expect(action1).toEqual(didFailToSetBoolean(SettingId.ShowDocs, testError));
|
||||
expect(action1).toEqual(
|
||||
didFailToSetBoolean(BooleanSettingId.ShowDocs, testError),
|
||||
);
|
||||
|
||||
// but the setting is still applied anyway
|
||||
const action2 = await saga.take();
|
||||
expect(action2).toEqual(didBooleanChange(SettingId.ShowDocs, true));
|
||||
expect(action2).toEqual(didBooleanChange(BooleanSettingId.ShowDocs, true));
|
||||
|
||||
mockSetItem.mockClear();
|
||||
|
||||
saga.put(setString(StringSettingId.HubName, 'test name'));
|
||||
expect(mockSetItem).toHaveBeenCalled();
|
||||
|
||||
// raises error that storing setting didn't work
|
||||
const action3 = await saga.take();
|
||||
expect(action3).toEqual(didFailToSetString(StringSettingId.HubName, testError));
|
||||
|
||||
// but the setting is still applied anyway
|
||||
const action4 = await saga.take();
|
||||
expect(action4).toEqual(didStringChange(StringSettingId.HubName, 'test name'));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
@@ -249,11 +269,11 @@ describe('store settings to local storage', () => {
|
||||
expect(value).toBe('true');
|
||||
});
|
||||
|
||||
saga.put(setBoolean(SettingId.ShowDocs, true));
|
||||
saga.put(setBoolean(BooleanSettingId.ShowDocs, true));
|
||||
expect(mockSetItem).toHaveBeenCalled();
|
||||
|
||||
const action = await saga.take();
|
||||
expect(action).toEqual(didBooleanChange(SettingId.ShowDocs, true));
|
||||
expect(action).toEqual(didBooleanChange(BooleanSettingId.ShowDocs, true));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
@@ -268,11 +288,11 @@ describe('store settings to local storage', () => {
|
||||
expect(value).toBe('false');
|
||||
});
|
||||
|
||||
saga.put(setBoolean(SettingId.DarkMode, false));
|
||||
saga.put(setBoolean(BooleanSettingId.DarkMode, false));
|
||||
expect(mockSetItem).toHaveBeenCalled();
|
||||
|
||||
const action = await saga.take();
|
||||
expect(action).toEqual(didBooleanChange(SettingId.DarkMode, false));
|
||||
expect(action).toEqual(didBooleanChange(BooleanSettingId.DarkMode, false));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
@@ -289,11 +309,13 @@ describe('store settings to local storage', () => {
|
||||
expect(value).toBe('false');
|
||||
});
|
||||
|
||||
saga.put(setBoolean(SettingId.FlashCurrentProgram, false));
|
||||
saga.put(setBoolean(BooleanSettingId.FlashCurrentProgram, false));
|
||||
expect(mockSetItem).toHaveBeenCalled();
|
||||
|
||||
const action = await saga.take();
|
||||
expect(action).toEqual(didBooleanChange(SettingId.FlashCurrentProgram, false));
|
||||
expect(action).toEqual(
|
||||
didBooleanChange(BooleanSettingId.FlashCurrentProgram, false),
|
||||
);
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
@@ -328,7 +350,7 @@ describe('storage monitor', () => {
|
||||
);
|
||||
|
||||
const action = await saga.take();
|
||||
expect(action).toEqual(didBooleanChange(SettingId.ShowDocs, true));
|
||||
expect(action).toEqual(didBooleanChange(BooleanSettingId.ShowDocs, true));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
@@ -362,11 +384,11 @@ describe('toggle', () => {
|
||||
expect(value).toBe('true');
|
||||
});
|
||||
|
||||
saga.put(toggleBoolean(SettingId.ShowDocs));
|
||||
saga.put(toggleBoolean(BooleanSettingId.ShowDocs));
|
||||
expect(mockSetItem).toHaveBeenCalled();
|
||||
|
||||
const action = await saga.take();
|
||||
expect(action).toEqual(didBooleanChange(SettingId.ShowDocs, true));
|
||||
expect(action).toEqual(didBooleanChange(BooleanSettingId.ShowDocs, true));
|
||||
|
||||
await saga.end();
|
||||
});
|
||||
|
||||
+67
-12
@@ -13,12 +13,20 @@ import { ensureError } from '../utils';
|
||||
import {
|
||||
SettingsActionType,
|
||||
SettingsSetBooleanAction,
|
||||
SettingsSetStringAction,
|
||||
SettingsToggleBooleanAction,
|
||||
didBooleanChange,
|
||||
didFailToSetBoolean,
|
||||
didFailToSetString,
|
||||
didStringChange,
|
||||
setBoolean,
|
||||
} from './actions';
|
||||
import { SettingId, getDefaultBooleanValue } from './defaults';
|
||||
import {
|
||||
BooleanSettingId,
|
||||
StringSettingId,
|
||||
getDefaultBooleanValue,
|
||||
getDefaultStringValue,
|
||||
} from './defaults';
|
||||
|
||||
function stringToBoolean(value: string): boolean {
|
||||
return value.toLowerCase().match(/(true|yes|1)/) !== null;
|
||||
@@ -49,20 +57,30 @@ function* monitorLocalStorage(): Generator {
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = event.key.replace(/^setting\./, '') as SettingId;
|
||||
const id = event.key.replace(/^setting\./, '');
|
||||
|
||||
// istanbul ignore if: should not happen normally
|
||||
if (!Object.values(SettingId).includes(id)) {
|
||||
console.error(`Bad setting id: ${id}`);
|
||||
if (Object.values(BooleanSettingId).includes(id as BooleanSettingId)) {
|
||||
yield* put(
|
||||
didBooleanChange(
|
||||
id as BooleanSettingId,
|
||||
stringToBoolean(event.newValue || 'false'),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
yield* put(didBooleanChange(id, stringToBoolean(event.newValue || 'false')));
|
||||
if (Object.values(StringSettingId).includes(id as StringSettingId)) {
|
||||
yield* put(didStringChange(id as StringSettingId, event.newValue || ''));
|
||||
continue;
|
||||
}
|
||||
|
||||
// istanbul ignore next: should not happen normally
|
||||
console.error(`Bad setting id: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function* loadSettings(): Generator {
|
||||
for (const id of Object.values(SettingId)) {
|
||||
for (const id of Object.values(BooleanSettingId)) {
|
||||
const storageValue = localStorage.getItem(`setting.${id}`);
|
||||
const defaultValue = getDefaultBooleanValue(id);
|
||||
const value =
|
||||
@@ -72,9 +90,19 @@ function* loadSettings(): Generator {
|
||||
yield* put(didBooleanChange(id, value));
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of Object.values(StringSettingId)) {
|
||||
const storageValue = localStorage.getItem(`setting.${id}`);
|
||||
const defaultValue = getDefaultStringValue(id);
|
||||
const value = storageValue === null ? defaultValue : storageValue;
|
||||
|
||||
if (value !== defaultValue) {
|
||||
yield* put(didStringChange(id, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function* storeSetting(action: SettingsSetBooleanAction): Generator {
|
||||
function* storeBooleanSetting(action: SettingsSetBooleanAction): Generator {
|
||||
const key = `setting.${action.id}`;
|
||||
const newValue = String(action.newState);
|
||||
|
||||
@@ -100,14 +128,41 @@ function* storeSetting(action: SettingsSetBooleanAction): Generator {
|
||||
}
|
||||
}
|
||||
|
||||
function* toggleSetting(action: SettingsToggleBooleanAction): Generator {
|
||||
function* toggleBooleanSetting(action: SettingsToggleBooleanAction): Generator {
|
||||
const oldValue = yield* select((s: RootState) => s.settings[action.id]);
|
||||
yield* storeSetting(setBoolean(action.id, !oldValue));
|
||||
yield* storeBooleanSetting(setBoolean(action.id, !oldValue));
|
||||
}
|
||||
|
||||
function* storeStringSetting(action: SettingsSetStringAction): Generator {
|
||||
const key = `setting.${action.id}`;
|
||||
const newValue = action.newState;
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, newValue);
|
||||
} catch (err) {
|
||||
yield* put(didFailToSetString(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 oldValue = yield* select((s: RootState) => s.settings[action.id]);
|
||||
if (action.newState !== oldValue) {
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key,
|
||||
newValue,
|
||||
oldValue,
|
||||
storageArea: localStorage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function* (): Generator {
|
||||
yield* fork(monitorLocalStorage);
|
||||
yield* takeEvery(AppActionType.DidStart, loadSettings);
|
||||
yield* takeEvery(SettingsActionType.SetBoolean, storeSetting);
|
||||
yield* takeEvery(SettingsActionType.ToggleBoolean, toggleSetting);
|
||||
yield* takeEvery(SettingsActionType.SetBoolean, storeBooleanSetting);
|
||||
yield* takeEvery(SettingsActionType.ToggleBoolean, toggleBooleanSetting);
|
||||
yield* takeEvery(SettingsActionType.SetString, storeStringSetting);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
|
||||
// Custom styling for the settings controls.
|
||||
|
||||
@import '../variables.scss';
|
||||
|
||||
.pb-hub-name-input .#{$ns}-input {
|
||||
width: 200px;
|
||||
}
|
||||
Reference in New Issue
Block a user