firmware: add multi-step dialog

This commit is contained in:
David Lechner
2022-07-20 12:32:17 -05:00
parent b70777178c
commit 85deb42f76
42 changed files with 1119 additions and 327 deletions
+16 -29
View File
@@ -4,6 +4,7 @@
import { cleanup, getByLabelText, waitFor } from '@testing-library/react';
import React from 'react';
import { testRender } from '../../test';
import { firmwareInstallPybricks, firmwareRestoreLego } from '../firmware/actions';
import Settings from './Settings';
afterEach(() => {
@@ -41,41 +42,27 @@ describe('darkMode setting switch', () => {
});
});
describe('flashCurrentProgram setting switch', () => {
it('should toggle the setting', async () => {
const [user, settings] = testRender(<Settings />);
describe('firmware', () => {
it('should dispatch action when install Pybricks firmware button is clicked', async () => {
const [user, settings, dispatch] = testRender(<Settings />);
expect(localStorage.getItem('setting.flashCurrentProgram')).toBe(null);
const button = settings.getByRole('button', {
name: 'Install Pybricks Firmware',
});
await user.click(button);
await user.click(settings.getByLabelText('Include current program'));
expect(localStorage.getItem('setting.flashCurrentProgram')).toBe('true');
await user.click(settings.getByLabelText('Include current program'));
expect(localStorage.getItem('setting.flashCurrentProgram')).toBe('false');
});
});
describe('hubName setting', () => {
it('should migrate old settings', () => {
// old settings did not use json format, so lack quotes
localStorage.setItem('setting.hubName', 'old name');
const [, settings] = testRender(<Settings />);
const textBox = settings.getByLabelText('Hub name');
expect(textBox).toHaveValue('old name');
expect(dispatch).toHaveBeenCalledWith(firmwareInstallPybricks());
});
it('should update the setting', async () => {
const [user, settings] = testRender(<Settings />);
it('should dispatch action when restore official LEGO firmware button is clicked', async () => {
const [user, settings, dispatch] = testRender(<Settings />);
expect(localStorage.getItem('setting.hubName')).toBe(null);
const button = settings.getByRole('button', {
name: 'Restore Official LEGO® Firmware',
});
await user.click(button);
const textBox = settings.getByLabelText('Hub name');
await user.type(textBox, 'test name');
expect(localStorage.getItem('setting.hubName')).toBe('"test name"');
expect(dispatch).toHaveBeenCalledWith(firmwareRestoreLego());
});
});
+16 -59
View File
@@ -6,10 +6,6 @@ import {
ButtonGroup,
ControlGroup,
FormGroup,
Icon,
InputGroup,
Intent,
Label,
Switch,
} from '@blueprintjs/core';
import React, { useState } from 'react';
@@ -18,7 +14,6 @@ import { useTernaryDarkMode } from 'usehooks-ts';
import AboutDialog from '../about/AboutDialog';
import { appCheckForUpdate, appReload, appShowInstallPrompt } from '../app/actions';
import {
appName,
pybricksBugReportsUrl,
pybricksGitterUrl,
pybricksProjectsUrl,
@@ -26,15 +21,13 @@ import {
} from '../app/constants';
import { Button } from '../components/Button';
import HelpButton from '../components/HelpButton';
import { firmwareInstallPybricks, firmwareRestoreLego } from '../firmware/actions';
import { InstallPybricksDialog } from '../firmware/installPybricksDialog/InstallPybricksDialog';
import { pseudolocalize } from '../i18n';
import { useSelector } from '../reducers';
import ExternalLinkIcon from '../utils/ExternalLinkIcon';
import { isMacOS } from '../utils/os';
import {
useSettingFlashCurrentProgram,
useSettingHubName,
useSettingIsShowDocsEnabled,
} from './hooks';
import { useSettingIsShowDocsEnabled } from './hooks';
import { I18nId, useI18n } from './i18n';
import './settings.scss';
@@ -44,8 +37,6 @@ const Settings: React.VoidFunctionComponent = () => {
const [isAboutDialogOpen, setIsAboutDialogOpen] = useState(false);
const { isDarkMode, setTernaryDarkMode } = useTernaryDarkMode();
const [isFlashCurrentProgramEnabled, setIsFlashCurrentProgramEnabled] =
useSettingFlashCurrentProgram();
const isServiceWorkerRegistered = useSelector(
(s) => s.app.isServiceWorkerRegistered,
);
@@ -56,7 +47,6 @@ const Settings: React.VoidFunctionComponent = () => {
);
const promptingInstall = useSelector((s) => s.app.promptingInstall);
const readyForOfflineUse = useSelector((s) => s.app.readyForOfflineUse);
const { hubName, isHubNameValid, setHubName } = useSettingHubName();
const dispatch = useDispatch();
@@ -107,52 +97,19 @@ const Settings: React.VoidFunctionComponent = () => {
</ControlGroup>
</FormGroup>
<FormGroup label={i18n.translate(I18nId.FirmwareTitle)}>
<ControlGroup>
<Switch
label={i18n.translate(I18nId.FirmwareCurrentProgramLabel)}
checked={isFlashCurrentProgramEnabled}
onChange={(e) =>
setIsFlashCurrentProgramEnabled(
(e.target as HTMLInputElement).checked,
)
}
/>
<HelpButton
helpForLabel={i18n.translate(
I18nId.FirmwareCurrentProgramLabel,
)}
content={i18n.translate(I18nId.FirmwareCurrentProgramHelp, {
appName,
})}
/>
</ControlGroup>
<Label htmlFor="hub-name-input">
{i18n.translate(I18nId.FirmwareHubNameLabel)}
</Label>
<ControlGroup>
<InputGroup
id="hub-name-input"
value={hubName}
onChange={(e) => setHubName(e.currentTarget.value)}
onMouseOver={(e) => e.preventDefault()}
onMouseDown={(e) => e.stopPropagation()}
intent={isHubNameValid ? Intent.NONE : Intent.DANGER}
placeholder="Pybricks Hub"
rightElement={
isHubNameValid ? undefined : (
<Icon
icon="error"
intent={Intent.DANGER}
itemType="div"
/>
)
}
/>
<HelpButton
helpForLabel={i18n.translate(I18nId.FirmwareHubNameLabel)}
content={i18n.translate(I18nId.FirmwareHubNameHelp)}
/>
</ControlGroup>
<Button
minimal={true}
icon="download"
label={i18n.translate(I18nId.FirmwareFlashPybricksLabel)}
onPress={() => dispatch(firmwareInstallPybricks())}
/>
<InstallPybricksDialog />
<Button
minimal={true}
icon="download"
label={i18n.translate(I18nId.FirmwareFlashLegoLabel)}
onPress={() => dispatch(firmwareRestoreLego())}
/>
</FormGroup>
<FormGroup label={i18n.translate(I18nId.HelpTitle)}>
<ButtonGroup minimal={true} vertical={true} alignText="left">
+2 -50
View File
@@ -1,13 +1,8 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { Dispatch, SetStateAction, useCallback } from 'react';
import { useIsFirstRender, useLocalStorage } from 'usehooks-ts';
const encoder = new TextEncoder();
// this is private type from usehooks-ts
type SetValue<T> = Dispatch<SetStateAction<T>>;
import { useCallback } from 'react';
import { useLocalStorage } from 'usehooks-ts';
/** Hook for "showDocs" setting. */
export function useSettingIsShowDocsEnabled(): {
@@ -31,46 +26,3 @@ export function useSettingIsShowDocsEnabled(): {
toggleIsSettingShowDocsEnabled,
};
}
/** Hook for "flashCurrentProgram" setting. */
export function useSettingFlashCurrentProgram(): [boolean, SetValue<boolean>] {
return useLocalStorage<boolean>('setting.flashCurrentProgram', false);
}
/**
* Validates the hub name.
* @param hubName The hub name.
* @returns True if the name if valid, otherwise false.
*/
function validateHubName(hubName: string): boolean {
const encoded = encoder.encode(hubName);
// 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 have 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;
}
/** Hook for "hubName" setting. */
export function useSettingHubName(): {
hubName: string;
isHubNameValid: boolean;
setHubName: (value: string) => void;
} {
if (useIsFirstRender()) {
// in version 1.x, settings didn't use json format, so we have to migrate
const oldSetting = localStorage.getItem('setting.hubName');
if (oldSetting !== null && !oldSetting.startsWith('"')) {
localStorage.setItem('setting.hubName', JSON.stringify(oldSetting));
}
}
const [hubName, setHubName] = useLocalStorage('setting.hubName', '');
const isHubNameValid = validateHubName(hubName);
return { hubName, isHubNameValid, setHubName };
}
+2 -5
View File
@@ -20,11 +20,8 @@ export enum I18nId {
AppearanceDarkModeHelp = 'appearance.darkMode.help',
AppearanceZoomHelp = 'appearance.zoom.help',
FirmwareTitle = 'firmware.title',
FirmwareCurrentProgramLabel = 'firmware.flashCurrentProgram.label',
FirmwareCurrentProgramHelp = 'firmware.flashCurrentProgram.help',
FirmwareHubNameLabel = 'firmware.hubName.label',
FirmwareHubNameHelp = 'firmware.hubName.help',
FirmwareHubNameError = 'firmware.hubName.error',
FirmwareFlashPybricksLabel = 'firmware.flashPybricksButton.label',
FirmwareFlashLegoLabel = 'firmware.flashLegoButton.label',
HelpTitle = 'help.title',
HelpProjectsLabel = 'help.projects.label',
HelpSupportLabel = 'help.support.label',
+4 -7
View File
@@ -16,14 +16,11 @@
},
"firmware": {
"title": "Firmware",
"flashCurrentProgram": {
"label": "Include current program",
"help": "Enable to include your program when flashing the firmware or disable to use the default program. Flashing your program along with the firmware will allow you to run your program without being connected to {appName}"
"flashPybricksButton": {
"label": "Install Pybricks Firmware"
},
"hubName": {
"label": "Hub name",
"help": "Enter a name here to customize the hub name when flashing the firmware. This name will be used in the Bluetooth advertising data and can be used to identify the hub when connecting.",
"error": "The name is too long."
"flashLegoButton": {
"label": "Restore Official LEGO® Firmware"
}
},
"help": {