mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
+47
-2
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,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
@@ -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,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": {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user