Merge pull request #672 from pybricks/dlech

v1.1.0-rc.1
This commit is contained in:
David Lechner
2021-11-19 20:58:54 -06:00
committed by GitHub
17 changed files with 509 additions and 72 deletions
+47 -2
View File
@@ -4,6 +4,50 @@
## [Unreleased]
## [1.1.0-rc.1] - 2021-11-19
### Added
- Hub name setting for selecting hub name when flashing firmware ([support#52]).
### Changed
- Updated to Pybricks firmware v3.1.0c1:
### Added
- Added `DriveBase.curve()` method to drive an arc segment.
- Added `then` and `wait` arguments to `DriveBase` methods ([support#57]).
### Changed
- Dropped `integral_range` argument from `Control.pid()`. This setting was
ineffective and never used. When set incorrectly, the motor could get stuck
for certain combinations of `kp` and `ki`.
- Improved motor behavior for cases with low-speed, low-load, but high
inertia ([support#366]).
- Changed how the duty cycle limit is set for `Motor` and `DCMotor`. It is now
set as a voltage limit via a dedicated method, instead of `Motor.control`.
### Fixed
- Fixed `then=Stop.COAST` being ignored in most motor commands.
- Fixed `brake()`/`light.off()` not working on Move hub I/O port C ([support#501]).
- Fixed `Remote()` failing to connect when hub is connected to 2019 or newer
MacBooks ([support#397]).
- Fixed intermittent improper detection of hot-plugged I/O devices ([support#500]).
- A program now stops when a `Motor` is unplugged while it is running, instead
of getting in a bad state.
[support#57]: https://github.com/pybricks/support/issues/57
[support#366]: https://github.com/pybricks/support/issues/366
[support#397]: https://github.com/pybricks/support/issues/397
[support#500]: https://github.com/pybricks/support/issues/500
[support#501]: https://github.com/pybricks/support/issues/501
- Updated docs:
### Added
- Added `ColorLightMatrix` class.
- Added `LWP3Device` class.
[support#52]: https://github.com/pybricks/support/issues/52
## [1.1.0-beta.6] - 2021-09-21
### Added
@@ -129,7 +173,7 @@
- Updated projects URL.
- Updated hub firmware to [v3.1.0a1].
- Updated docs.
-
-
[v3.1.0a1]: https://github.com/pybricks/pybricks-micropython/blob/master/CHANGELOG.md#310a1---2021-06-23
## [1.0.0] - 2021-06-08
@@ -150,7 +194,8 @@ Prerelease changes are documented at [support#48].
<!-- links for version headings -->
[Unreleased]: https://github.com/pybricks/pybricks-code/compare/v1.1.0-beta.6...HEAD
[Unreleased]: https://github.com/pybricks/pybricks-code/compare/v1.1.0-rc.1...HEAD
[1.1.0-rc.1]: https://github.com/pybricks/pybricks-code/compare/v1.1.0-beta.6...v1.1.0-rc.1
[1.1.0-beta.6]: https://github.com/pybricks/pybricks-code/compare/v1.1.0-beta.5...v1.1.0-beta.6
[1.1.0-beta.5]: https://github.com/pybricks/pybricks-code/compare/v1.1.0-beta.4...v1.1.0-beta.5
[1.1.0-beta.4]: https://github.com/pybricks/pybricks-code/compare/v1.1.0-beta.3...v1.1.0-beta.4
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@pybricks/pybricks-code",
"version": "1.1.0-beta.6",
"version": "1.1.0-rc.1",
"license": "MIT",
"author": "The Pybricks Authors",
"repository": {
+2 -2
View File
@@ -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));
}
});
+2 -2
View File
@@ -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(
+4 -2
View File
@@ -63,6 +63,8 @@ describe('flashFirmware', () => {
'mpy-abi-version': 5,
'mpy-cross-options': ['-mno-unicode'],
'user-mpy-offset': 100,
'hub-name-offset': 90,
'max-hub-name-size': 10,
};
const zip = new JSZip();
@@ -79,7 +81,7 @@ describe('flashFirmware', () => {
flashFirmware,
{
bootloader: { connection: BootloaderConnectionState.Disconnected },
settings: { flashCurrentProgram: false },
settings: { flashCurrentProgram: false, hubName: 'test name' },
},
{
nextMessageId: createCountFunc(),
@@ -185,7 +187,7 @@ describe('flashFirmware', () => {
// hub indicates success
saga.put(programResponse(0x62, totalFirmwareSize));
saga.put(programResponse(0x33, totalFirmwareSize));
action = await saga.take();
expect(action).toEqual(didProgress(1));
+16 -1
View File
@@ -1,7 +1,12 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2021 The Pybricks Authors
import { FirmwareReader, FirmwareReaderError, HubType } from '@pybricks/firmware';
import {
FirmwareReader,
FirmwareReaderError,
HubType,
encodeHubName,
} from '@pybricks/firmware';
import cityHubZip from '@pybricks/firmware/build/cityhub.zip';
import moveHubZip from '@pybricks/firmware/build/movehub.zip';
import technicHubZip from '@pybricks/firmware/build/technichub.zip';
@@ -241,6 +246,16 @@ function* loadFirmware(
firmwareView.setUint32(metadata['user-mpy-offset'], mpy.data.length, true);
firmware.set(mpy.data, metadata['user-mpy-offset'] + 4);
// if the firmware supports it, we can set a custom hub name
if (metadata['max-hub-name-size']) {
const hubName = yield* select((s: RootState) => s.settings.hubName);
// empty string means use default name (don't write over firmware)
if (hubName) {
firmware.set(encodeHubName(hubName, metadata), metadata['hub-name-offset']);
}
}
if (metadata['checksum-type'] !== 'sum') {
yield* put(
didFailToFinish(
+4
View File
@@ -93,3 +93,7 @@ a.#{$ns}-button {
::-webkit-scrollbar-thumb:hover {
background: $pt-icon-color-hover;
}
.#{$ns}-input-group .#{$ns}-icon {
margin: 7px;
}
+71 -6
View File
@@ -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,55 @@ class SettingsDrawer extends React.PureComponent<SettingsProps> {
}
/>
</Tooltip>
<ControlGroup>
<Tooltip
content={i18n.translate(
SettingsStringId.FirmwareHubNameTooltip,
)}
boundary="window"
position={Position.LEFT}
targetTagName="div"
hoverOpenDelay={tooltipDelay}
>
<Label
className={Classes.INLINE}
htmlFor="hub-name-input"
>
{i18n.translate(
SettingsStringId.FirmwareHubNameLabel,
)}
</Label>
</Tooltip>
<InputGroup
id="hub-name-input"
value={hubName}
onChange={onHubNameChange}
onMouseOver={(e) => e.preventDefault()}
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>
)
}
/>
</ControlGroup>
</FormGroup>
<FormGroup label={i18n.translate(SettingsStringId.HelpTitle)}>
<ButtonGroup
@@ -328,17 +389,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
View File
@@ -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;
+20 -6
View File
@@ -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}`);
}
}
+7
View File
@@ -20,6 +20,13 @@
"flash-current-program": {
"label": "Include current program",
"tooltip": "Select to include your program when installing the firmware."
},
"hub-name": {
"label": "Hub name",
"tooltip": "Hub name to use when flashing the firmware.",
"error": {
"tooltip": "The name is too long."
}
}
},
"help": {
+3
View File
@@ -14,6 +14,9 @@ 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',
FirmwareHubNameTooltip = 'settings.firmware.hub-name.tooltip',
FirmwareHubNameErrorTooltip = 'settings.firmware.hub-name.error.tooltip',
HelpTitle = 'settings.help.title',
HelpProjectsLabel = 'settings.help.projects.label',
HelpSupportLabel = 'settings.help.support.label',
+100
View File
@@ -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);
});
});
+55 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+11
View File
@@ -0,0 +1,11 @@
// 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;
margin-left: 8px;
}