mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 09:36:27 +00:00
firmware: add multi-step dialog
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
|
||||
### Added
|
||||
- Added better error message when no files to backup ([support#681]).
|
||||
- Added multi-step firmware flashing dialog.
|
||||
|
||||
### Fixed
|
||||
- Fixed deleting files that are not open in the editor.
|
||||
|
||||
@@ -34,6 +34,8 @@ function* handleShowAlert(action: ReturnType<typeof alertsShowAlert>): Generator
|
||||
|
||||
try {
|
||||
const alertAction = yield* take(chan);
|
||||
// the dismiss actions will have called this already, but other actions don't
|
||||
toaster.dismiss(key);
|
||||
|
||||
yield* put(alertsDidShowAlert(action.domain, action.specific, alertAction));
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
// Copyright (c) 2021-2022 The Pybricks Authors
|
||||
|
||||
// Definitions for compile-time UI settings.
|
||||
|
||||
@@ -32,6 +32,9 @@ export const pybricksBugReportsUrl = 'https://github.com/pybricks/support/issues
|
||||
/** URL for Pybricks community chat on Gitter */
|
||||
export const pybricksGitterUrl = 'https://gitter.im/pybricks/community';
|
||||
|
||||
export const pybricksBluetoothTroubleshootingUrl =
|
||||
'https://github.com/pybricks/support/discussions/270';
|
||||
|
||||
/** Pybricks copyright statement. */
|
||||
export const pybricksCopyright = 'Copyright (c) 2020-2022 The Pybricks Authors';
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import './noHub.scss';
|
||||
import { AnchorButton, Button, Intent } from '@blueprintjs/core';
|
||||
import React from 'react';
|
||||
import { appName, pybricksBluetoothTroubleshootingUrl } from '../../app/constants';
|
||||
import { CreateToast } from '../../i18nToaster';
|
||||
import ExternalLinkIcon from '../../utils/ExternalLinkIcon';
|
||||
import { I18nId, useI18n } from './i18n';
|
||||
|
||||
type NoHubProps = {
|
||||
onFlashFirmware: () => void;
|
||||
};
|
||||
|
||||
const NoHub: React.VoidFunctionComponent<NoHubProps> = ({ onFlashFirmware }) => {
|
||||
const i18n = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>{i18n.translate(I18nId.NoHubMessage)}</p>
|
||||
<p>
|
||||
{i18n.translate(I18nId.NoHubSuggestion1, {
|
||||
appName,
|
||||
buttonName: (
|
||||
<strong>
|
||||
{i18n.translate(I18nId.NoHubFlashFirmwareButton)}
|
||||
</strong>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
<p>{i18n.translate(I18nId.NoHubSuggestion2)}</p>
|
||||
<div className="pb-ble-alerts-noHub-buttons">
|
||||
<Button icon="download" onClick={onFlashFirmware}>
|
||||
{i18n.translate(I18nId.NoHubFlashFirmwareButton)}
|
||||
</Button>
|
||||
<AnchorButton
|
||||
icon="help"
|
||||
href={pybricksBluetoothTroubleshootingUrl}
|
||||
target="_blank"
|
||||
>
|
||||
{i18n.translate(I18nId.NoHubTroubleshootButton)}
|
||||
<ExternalLinkIcon />
|
||||
</AnchorButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const noHub: CreateToast<never, 'dismiss' | 'flashFirmware'> = (onAction) => {
|
||||
return {
|
||||
message: <NoHub onFlashFirmware={() => onAction('flashFirmware')} />,
|
||||
icon: 'info-sign',
|
||||
intent: Intent.PRIMARY,
|
||||
timeout: 15000,
|
||||
onDismiss: () => onAction('dismiss'),
|
||||
};
|
||||
};
|
||||
@@ -19,4 +19,9 @@ export enum I18nId {
|
||||
MissingServiceMessage = 'missingService.message',
|
||||
MissingServiceSuggestion1 = 'missingService.suggestion1',
|
||||
MissingServiceSuggestion2 = 'missingService.suggestion2',
|
||||
NoHubMessage = 'noHub.message',
|
||||
NoHubSuggestion1 = 'noHub.suggestion1',
|
||||
NoHubSuggestion2 = 'noHub.suggestion2',
|
||||
NoHubFlashFirmwareButton = 'noHub.flashFirmwareButton',
|
||||
NoHubTroubleshootButton = 'noHub.troubleshootButton',
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import { bluetoothNotAvailable } from './BluetoothNotAvailable';
|
||||
import { missingService } from './MissingService';
|
||||
import { noGatt } from './NoGatt';
|
||||
import { noHub } from './NoHub';
|
||||
import { noWebBluetooth } from './NoWebBluetooth';
|
||||
|
||||
// gathers all of the alert creation functions for passing up to the top level
|
||||
export default { bluetoothNotAvailable, missingService, noGatt, noWebBluetooth };
|
||||
export default { bluetoothNotAvailable, missingService, noGatt, noHub, noWebBluetooth };
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
.pb-ble-alerts-noHub {
|
||||
&-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
@@ -16,5 +16,12 @@
|
||||
"message": "Connected to hub but failed to get {serviceName} service.",
|
||||
"suggestion1": "Ensure that you are using the most recent firmware.",
|
||||
"suggestion2": "If the problem persists, try removing the \"{hubName}\" device in your OS Bluetooth settings, then try connecting again."
|
||||
},
|
||||
"noHub": {
|
||||
"message": "Could not find your hub?",
|
||||
"suggestion1": "{appName} requires custom firmware to be flashed to your hub. If you have not done this already, click the {buttonName} button below to do so now.",
|
||||
"suggestion2": "If you have flashed the Pybricks firmware to the hub already and you are still having problems connecting, please visit the troubleshooting guide.",
|
||||
"flashFirmwareButton": "Flash Firmware",
|
||||
"troubleshootButton": "Troubleshooting Tips"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { HubType } from '@pybricks/firmware';
|
||||
import { MockProxy, mock } from 'jest-mock-extended';
|
||||
import { AsyncSaga } from '../../test';
|
||||
import { alertsShowAlert } from '../alerts/actions';
|
||||
import { alertsDidShowAlert, alertsShowAlert } from '../alerts/actions';
|
||||
import {
|
||||
bleDIServiceDidReceiveFirmwareRevision,
|
||||
bleDIServiceDidReceivePnPId,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
pybricksControlCharacteristicUUID,
|
||||
pybricksServiceUUID,
|
||||
} from '../ble-pybricks-service/protocol';
|
||||
import { firmwareInstallPybricks } from '../firmware/actions';
|
||||
import {
|
||||
bleConnectPybricks,
|
||||
bleDidConnectPybricks,
|
||||
@@ -274,7 +275,11 @@ describe('connect action is dispatched', () => {
|
||||
|
||||
await runConnectUntil(saga, ConnectRunPoint.Connect);
|
||||
|
||||
await expect(saga.take()).resolves.toEqual(alertsShowAlert('ble', 'noHub'));
|
||||
await expect(saga.take()).resolves.toEqual(bleDidFailToConnectPybricks());
|
||||
|
||||
saga.put(alertsDidShowAlert('ble', 'noHub', 'flashFirmware'));
|
||||
await expect(saga.take()).resolves.toEqual(firmwareInstallPybricks());
|
||||
});
|
||||
|
||||
it('should fail on other exception in requestDevice', async () => {
|
||||
|
||||
+16
-1
@@ -17,7 +17,7 @@ import {
|
||||
take,
|
||||
takeEvery,
|
||||
} from 'typed-redux-saga/macro';
|
||||
import { alertsShowAlert } from '../alerts/actions';
|
||||
import { alertsDidShowAlert, alertsShowAlert } from '../alerts/actions';
|
||||
import {
|
||||
bleDIServiceDidReceiveFirmwareRevision,
|
||||
bleDIServiceDidReceivePnPId,
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
pybricksControlCharacteristicUUID,
|
||||
pybricksServiceUUID,
|
||||
} from '../ble-pybricks-service/protocol';
|
||||
import { firmwareInstallPybricks } from '../firmware/actions';
|
||||
import { RootState } from '../reducers';
|
||||
import { ensureError } from '../utils';
|
||||
import {
|
||||
@@ -141,7 +142,21 @@ function* handleBleConnectPybricks(): Generator {
|
||||
);
|
||||
|
||||
if (!device) {
|
||||
yield* put(alertsShowAlert('ble', 'noHub'));
|
||||
yield* put(bleDidFailToConnectPybricks());
|
||||
|
||||
const { action } = yield* take<
|
||||
ReturnType<typeof alertsDidShowAlert<'ble', 'noHub'>>
|
||||
>(
|
||||
alertsDidShowAlert.when(
|
||||
(a) => a.domain === 'ble' && a.specific === 'noHub',
|
||||
),
|
||||
);
|
||||
|
||||
if (action === 'flashFirmware') {
|
||||
yield* put(firmwareInstallPybricks());
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { Radio, RadioGroup } from '@blueprintjs/core';
|
||||
import React from 'react';
|
||||
import { Hub } from '.';
|
||||
|
||||
type HubPickerProps = {
|
||||
hubType: Hub;
|
||||
onChange: (hubType: Hub) => void;
|
||||
};
|
||||
|
||||
export const HubPicker: React.VoidFunctionComponent<HubPickerProps> = ({
|
||||
hubType,
|
||||
onChange,
|
||||
}) => {
|
||||
return (
|
||||
<RadioGroup
|
||||
selectedValue={hubType}
|
||||
onChange={(e) => onChange(e.currentTarget.value as Hub)}
|
||||
>
|
||||
<Radio value={Hub.Move}>BOOST Move Hub</Radio>
|
||||
<Radio value={Hub.City}>City Hub</Radio>
|
||||
<Radio value={Hub.Technic}>Technic Hub</Radio>
|
||||
<Radio value={Hub.Prime}>SPIKE Prime Hub</Radio>
|
||||
<Radio value={Hub.Essential}>SPIKE Essential Hub</Radio>
|
||||
<Radio value={Hub.Inventor}>MINDSTORMS Robot Inventor Hub</Radio>
|
||||
</RadioGroup>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
/** Supported hub types. */
|
||||
export enum Hub {
|
||||
/** BOOST Move hub */
|
||||
Move = 'movehub',
|
||||
/** City hub */
|
||||
City = 'cityhub',
|
||||
/** Technic hub */
|
||||
Technic = 'technichub',
|
||||
/** MINDSTORMS Robot Inventor hub */
|
||||
Inventor = 'inventorhub',
|
||||
/** SPIKE Prime hub */
|
||||
Prime = 'primehub',
|
||||
/** SPIKE Essential hub */
|
||||
Essential = 'essentialhub',
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if hub has a USB port.
|
||||
*/
|
||||
export function hubHasUSB(hub: Hub): boolean {
|
||||
switch (hub) {
|
||||
case Hub.Prime:
|
||||
case Hub.Essential:
|
||||
case Hub.Inventor:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if hub has a Bluetooth button.
|
||||
*/
|
||||
export function hubHasBluetoothButton(hub: Hub): boolean {
|
||||
switch (hub) {
|
||||
case Hub.Prime:
|
||||
case Hub.Inventor:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if hub has external flash memory.
|
||||
*/
|
||||
export function hubHasExternalFlash(hub: Hub): boolean {
|
||||
switch (hub) {
|
||||
case Hub.Prime:
|
||||
case Hub.Essential:
|
||||
case Hub.Inventor:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@ import { fireEvent, waitFor } from '@testing-library/dom';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { testRender } from '../../../test';
|
||||
import { Hub } from '../../components/hubPicker';
|
||||
import NewFileWizard from './NewFileWizard';
|
||||
import { Hub, newFileWizardDidAccept, newFileWizardDidCancel } from './actions';
|
||||
import { newFileWizardDidAccept, newFileWizardDidCancel } from './actions';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import {
|
||||
Button,
|
||||
Classes,
|
||||
Dialog,
|
||||
FormGroup,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
} from '@blueprintjs/core';
|
||||
import { Button, Classes, Dialog, FormGroup } from '@blueprintjs/core';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useId } from 'react-aria';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Hub } from '../../components/hubPicker';
|
||||
import { HubPicker } from '../../components/hubPicker/HubPicker';
|
||||
import { useFileStorageMetadata } from '../../fileStorage/hooks';
|
||||
import {
|
||||
FileNameValidationResult,
|
||||
@@ -20,7 +15,7 @@ import {
|
||||
} from '../../pybricksMicropython/lib';
|
||||
import { useSelector } from '../../reducers';
|
||||
import FileNameFormGroup from '../fileNameFormGroup/FileNameFormGroup';
|
||||
import { Hub, newFileWizardDidAccept, newFileWizardDidCancel } from './actions';
|
||||
import { newFileWizardDidAccept, newFileWizardDidCancel } from './actions';
|
||||
import { I18nId, useI18n } from './i18n';
|
||||
|
||||
// This should be set to the most commonly used hub.
|
||||
@@ -75,19 +70,7 @@ const NewFileWizard: React.VoidFunctionComponent = () => {
|
||||
onChange={setFileName}
|
||||
/>
|
||||
<FormGroup label={i18n.translate(I18nId.SmartHubLabel)}>
|
||||
<RadioGroup
|
||||
selectedValue={hubType}
|
||||
onChange={(e) => setHubType(e.currentTarget.value as Hub)}
|
||||
>
|
||||
<Radio value={Hub.Move}>BOOST Move Hub</Radio>
|
||||
<Radio value={Hub.City}>City Hub</Radio>
|
||||
<Radio value={Hub.Technic}>Technic Hub</Radio>
|
||||
<Radio value={Hub.Prime}>SPIKE Prime</Radio>
|
||||
<Radio value={Hub.Essential}>SPIKE Essential</Radio>
|
||||
<Radio value={Hub.Inventor}>
|
||||
MINDSTORMS Robot Inventor
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
<HubPicker hubType={hubType} onChange={setHubType} />
|
||||
</FormGroup>
|
||||
</div>
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
|
||||
@@ -2,28 +2,13 @@
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { createAction } from '../../actions';
|
||||
import { Hub } from '../../components/hubPicker';
|
||||
|
||||
import { pythonFileExtension } from '../../pybricksMicropython/lib';
|
||||
|
||||
/** Supported file extensions. */
|
||||
type SupportedFileExtension = typeof pythonFileExtension;
|
||||
|
||||
/** Supported hub types. */
|
||||
export enum Hub {
|
||||
/** BOOST Move hub */
|
||||
Move = 'movehub',
|
||||
/** City hub */
|
||||
City = 'cityhub',
|
||||
/** Technic hub */
|
||||
Technic = 'technichub',
|
||||
/** MINDSTORMS Robot Inventor hub */
|
||||
Inventor = 'inventorhub',
|
||||
/** SPIKE Prime hub */
|
||||
Prime = 'primehub',
|
||||
/** SPIKE Essential hub */
|
||||
Essential = 'essentialhub',
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests to show the new file wizard dialog.
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,7 @@ import { FileWithHandle } from 'browser-fs-access';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { AsyncSaga, uuid } from '../../test';
|
||||
import { alertsShowAlert } from '../alerts/actions';
|
||||
import { Hub } from '../components/hubPicker';
|
||||
import {
|
||||
editorActivateFile,
|
||||
editorCloseFile,
|
||||
@@ -72,7 +73,6 @@ import {
|
||||
} from './duplicateFileDialog/actions';
|
||||
import { ExplorerError, ExplorerErrorName } from './error';
|
||||
import {
|
||||
Hub,
|
||||
newFileWizardDidAccept,
|
||||
newFileWizardDidCancel,
|
||||
newFileWizardShow,
|
||||
|
||||
@@ -345,3 +345,45 @@ function didFailToFinishCreator(
|
||||
* @param total The total number of bytes to be flashed.
|
||||
*/
|
||||
export const didFailToFinish = createAction(didFailToFinishCreator);
|
||||
|
||||
/**
|
||||
* Action that triggers the install Pybricks firmware saga.
|
||||
*/
|
||||
export const firmwareInstallPybricks = createAction(() => ({
|
||||
type: 'firmware.action.installPybricks',
|
||||
}));
|
||||
|
||||
/**
|
||||
* Action that indicates {@link firmwareInstallPybricks} succeeded.
|
||||
*/
|
||||
export const firmwareDidInstallPybricks = createAction(() => ({
|
||||
type: 'firmware.action.didInstallPybricks',
|
||||
}));
|
||||
|
||||
/**
|
||||
* Action that indicates {@link firmwareInstallPybricks} failed.
|
||||
*/
|
||||
export const firmwareDidFailToInstallPybricks = createAction(() => ({
|
||||
type: 'firmware.action.didFailToInstallPybricks',
|
||||
}));
|
||||
|
||||
/**
|
||||
* Action that triggers the restore LEGO firmware saga.
|
||||
*/
|
||||
export const firmwareRestoreLego = createAction(() => ({
|
||||
type: 'firmware.action.restoreLego',
|
||||
}));
|
||||
|
||||
/**
|
||||
* Action that indicates {@link firmwareRestoreLego} succeeded.
|
||||
*/
|
||||
export const firmwareDidRestoreLego = createAction(() => ({
|
||||
type: 'firmware.action.didRestoreLego',
|
||||
}));
|
||||
|
||||
/**
|
||||
* Action that indicates {@link firmwareRestoreLego} failed.
|
||||
*/
|
||||
export const firmwareDidFailToRestoreLego = createAction(() => ({
|
||||
type: 'firmware.action.didFailToRestoreLego',
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import './installPybricksDialog.scss';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Classes,
|
||||
ControlGroup,
|
||||
DialogStep,
|
||||
FormGroup,
|
||||
IRef,
|
||||
Icon,
|
||||
InputGroup,
|
||||
Intent,
|
||||
MultistepDialog,
|
||||
NonIdealState,
|
||||
Spinner,
|
||||
Switch,
|
||||
} from '@blueprintjs/core';
|
||||
import { Classes as Classes2, Popover2 } from '@blueprintjs/popover2';
|
||||
import classNames from 'classnames';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { appName } from '../../app/constants';
|
||||
import HelpButton from '../../components/HelpButton';
|
||||
import {
|
||||
Hub,
|
||||
hubHasBluetoothButton,
|
||||
hubHasExternalFlash,
|
||||
hubHasUSB,
|
||||
} from '../../components/hubPicker';
|
||||
import { HubPicker } from '../../components/hubPicker/HubPicker';
|
||||
import { useSelector } from '../../reducers';
|
||||
import {
|
||||
firmwareInstallPybricksDialogAccept,
|
||||
firmwareInstallPybricksDialogCancel,
|
||||
} from './actions';
|
||||
import { useFirmware } from './hooks';
|
||||
import { I18nId, useI18n } from './i18n';
|
||||
import { validateHubName } from '.';
|
||||
|
||||
const dialogBody = classNames(
|
||||
Classes.DIALOG_BODY,
|
||||
'pb-firmware-installPybricksDialog-body',
|
||||
);
|
||||
|
||||
type SelectHubPanelProps = {
|
||||
hubType: Hub;
|
||||
onChange: (hubType: Hub) => void;
|
||||
};
|
||||
|
||||
const SelectHubPanel: React.VoidFunctionComponent<SelectHubPanelProps> = ({
|
||||
hubType,
|
||||
onChange,
|
||||
}) => {
|
||||
const i18n = useI18n();
|
||||
|
||||
return (
|
||||
<div className={dialogBody}>
|
||||
<p>{i18n.translate(I18nId.SelectHubPanelMessage)}</p>
|
||||
<HubPicker hubType={hubType} onChange={onChange} />
|
||||
<Popover2
|
||||
popoverClassName={Classes2.POPOVER2_CONTENT_SIZING}
|
||||
placement="right-end"
|
||||
content={
|
||||
<div>
|
||||
<h3>
|
||||
{i18n.translate(
|
||||
I18nId.SelectHubPanelNotOnListButtonInfoMindstormsTitle,
|
||||
)}
|
||||
</h3>
|
||||
<ul>
|
||||
<li>
|
||||
{i18n.translate(
|
||||
I18nId.SelectHubPanelNotOnListButtonInfoMindstormsRcx,
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{i18n.translate(
|
||||
I18nId.SelectHubPanelNotOnListButtonInfoMindstormsNxt,
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{i18n.translate(
|
||||
I18nId.SelectHubPanelNotOnListButtonInfoMindstormsEv3,
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
<h3>
|
||||
{i18n.translate(
|
||||
I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpTitle,
|
||||
)}
|
||||
</h3>
|
||||
<ul>
|
||||
<li>
|
||||
{i18n.translate(
|
||||
I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpWedo2,
|
||||
)}
|
||||
<em>*</em>
|
||||
</li>
|
||||
<li>
|
||||
{i18n.translate(
|
||||
I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpDuploTrain,
|
||||
)}
|
||||
<em>*</em>
|
||||
</li>
|
||||
<li>
|
||||
{i18n.translate(
|
||||
I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpMario,
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<em>
|
||||
*{' '}
|
||||
{i18n.translate(
|
||||
I18nId.SelectHubPanelNotOnListButtonInfoPoweredUpFootnote,
|
||||
)}
|
||||
</em>
|
||||
</div>
|
||||
}
|
||||
renderTarget={({ isOpen: _isOpen, ref, ...targetProps }) => (
|
||||
<Button
|
||||
elementRef={ref as IRef<HTMLButtonElement>}
|
||||
{...targetProps}
|
||||
>
|
||||
{i18n.translate(I18nId.SelectHubPanelNotOnListButtonLabel)}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type AcceptLicensePanelProps = {
|
||||
hubType: Hub;
|
||||
licenseAccepted: boolean;
|
||||
onLicenseAcceptedChanged: (accepted: boolean) => void;
|
||||
};
|
||||
|
||||
const AcceptLicensePanel: React.VoidFunctionComponent<AcceptLicensePanelProps> = ({
|
||||
hubType,
|
||||
licenseAccepted,
|
||||
onLicenseAcceptedChanged,
|
||||
}) => {
|
||||
const { data, error } = useFirmware(hubType);
|
||||
const i18n = useI18n();
|
||||
|
||||
return (
|
||||
<div className={dialogBody}>
|
||||
<div className="pb-firmware-installPybricksDialog-license">
|
||||
<div className="pb-firmware-installPybricksDialog-license-text">
|
||||
{data && <pre>{data.licenseText}</pre>}
|
||||
{!data && (
|
||||
<NonIdealState>
|
||||
{(error &&
|
||||
i18n.translate(
|
||||
I18nId.LicensePanelLicenseTextError,
|
||||
)) || <Spinner />}
|
||||
</NonIdealState>
|
||||
)}
|
||||
</div>
|
||||
<Checkbox
|
||||
label={i18n.translate(I18nId.LicensePanelAcceptCheckboxLabel)}
|
||||
checked={licenseAccepted}
|
||||
onChange={(e) => onLicenseAcceptedChanged(e.currentTarget.checked)}
|
||||
disabled={!data}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SelectOptionsPanelProps = {
|
||||
hubType: Hub;
|
||||
hubName: string;
|
||||
includeProgram: boolean;
|
||||
onChangeHubName(hubName: string): void;
|
||||
onChangeIncludeProgram(includeProgram: boolean): void;
|
||||
};
|
||||
|
||||
const ConfigureOptionsPanel: React.VoidFunctionComponent<SelectOptionsPanelProps> = ({
|
||||
hubType,
|
||||
hubName,
|
||||
includeProgram,
|
||||
onChangeHubName,
|
||||
onChangeIncludeProgram,
|
||||
}) => {
|
||||
const i18n = useI18n();
|
||||
const isHubNameValid = validateHubName(hubName);
|
||||
|
||||
return (
|
||||
<div className={dialogBody}>
|
||||
<FormGroup
|
||||
label={i18n.translate(I18nId.OptionsPanelHubNameLabel)}
|
||||
labelInfo={i18n.translate(I18nId.OptionsPanelHubNameLabelInfo)}
|
||||
>
|
||||
<ControlGroup>
|
||||
<InputGroup
|
||||
id="hub-name-input"
|
||||
value={hubName}
|
||||
onChange={(e) => onChangeHubName(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.OptionsPanelHubNameLabel)}
|
||||
content={i18n.translate(I18nId.OptionsPanelHubNameHelp)}
|
||||
/>
|
||||
</ControlGroup>
|
||||
</FormGroup>
|
||||
<FormGroup
|
||||
label={i18n.translate(I18nId.OptionsPanelCustomMainLabel)}
|
||||
labelInfo={i18n.translate(I18nId.OptionsPanelCustomMainLabelInfo)}
|
||||
>
|
||||
{(hubHasExternalFlash(hubType) && (
|
||||
<p>
|
||||
{i18n.translate(
|
||||
I18nId.OptionsPanelCustomMainNotApplicableMessage,
|
||||
)}
|
||||
</p>
|
||||
)) || (
|
||||
<ControlGroup>
|
||||
<Switch
|
||||
label={i18n.translate(
|
||||
I18nId.OptionsPanelCustomMainIncludeCurrentProgramLabel,
|
||||
)}
|
||||
checked={includeProgram}
|
||||
onChange={(e) =>
|
||||
onChangeIncludeProgram(
|
||||
(e.target as HTMLInputElement).checked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<HelpButton
|
||||
helpForLabel={i18n.translate(
|
||||
I18nId.OptionsPanelCustomMainIncludeCurrentProgramLabel,
|
||||
)}
|
||||
content={i18n.translate(
|
||||
I18nId.OptionsPanelCustomMainIncludeCurrentProgramHelp,
|
||||
{
|
||||
appName,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</ControlGroup>
|
||||
)}
|
||||
</FormGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type BootloaderModePanelProps = {
|
||||
hubType: Hub;
|
||||
};
|
||||
|
||||
const BootloaderModePanel: React.VoidFunctionComponent<BootloaderModePanelProps> = ({
|
||||
hubType,
|
||||
}) => {
|
||||
const i18n = useI18n();
|
||||
|
||||
const { button, light, lightPattern } = useMemo(() => {
|
||||
return {
|
||||
button: i18n.translate(
|
||||
hubHasBluetoothButton(hubType)
|
||||
? I18nId.BootloaderPanelButtonBluetooth
|
||||
: I18nId.BootloaderPanelButtonPower,
|
||||
),
|
||||
light: i18n.translate(
|
||||
hubHasBluetoothButton(hubType)
|
||||
? I18nId.BootloaderPanelLightBluetooth
|
||||
: I18nId.BootloaderPanelLightStatus,
|
||||
),
|
||||
lightPattern: i18n.translate(
|
||||
hubHasBluetoothButton(hubType)
|
||||
? I18nId.BootloaderPanelLightPatternBluetooth
|
||||
: I18nId.BootloaderPanelLightPatternStatus,
|
||||
),
|
||||
};
|
||||
}, [i18n, hubType]);
|
||||
|
||||
return (
|
||||
<div className={dialogBody}>
|
||||
<p>{i18n.translate(I18nId.BootloaderPanelInstruction1)}</p>
|
||||
<ol>
|
||||
{hubHasUSB(hubType) && (
|
||||
<li>{i18n.translate(I18nId.BootloaderPanelStepDisconnectUsb)}</li>
|
||||
)}
|
||||
|
||||
<li>{i18n.translate(I18nId.BootloaderPanelStepPowerOff)}</li>
|
||||
|
||||
{/* City hub has power issues and requires disconnecting motors/sensors */}
|
||||
{hubType === Hub.City && (
|
||||
<li>{i18n.translate(I18nId.BootloaderPanelStepDisconnectIo)}</li>
|
||||
)}
|
||||
|
||||
<li>
|
||||
{i18n.translate(I18nId.BootloaderPanelStepHoldButton, { button })}
|
||||
</li>
|
||||
|
||||
{hubHasUSB(hubType) && (
|
||||
<li>{i18n.translate(I18nId.BootloaderPanelStepConnectUsb)}</li>
|
||||
)}
|
||||
|
||||
<li>
|
||||
{i18n.translate(I18nId.BootloaderPanelStepWaitForLight, {
|
||||
button,
|
||||
light,
|
||||
lightPattern,
|
||||
})}
|
||||
</li>
|
||||
|
||||
<li>
|
||||
{i18n.translate(
|
||||
/* hubs with USB will keep the power on, but other hubs won't */
|
||||
hubHasUSB(hubType)
|
||||
? I18nId.BootloaderPanelStepReleaseButton
|
||||
: I18nId.BootloaderPanelStepKeepHolding,
|
||||
{
|
||||
button,
|
||||
},
|
||||
)}
|
||||
</li>
|
||||
</ol>
|
||||
<p>
|
||||
{i18n.translate(I18nId.BootloaderPanelInstruction2, {
|
||||
flashFirmware: (
|
||||
<strong>
|
||||
{i18n.translate(I18nId.FlashFirmwareButtonLabel)}
|
||||
</strong>
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const defaultHubType = Hub.Technic;
|
||||
|
||||
export const InstallPybricksDialog: React.VoidFunctionComponent = () => {
|
||||
const { isOpen } = useSelector((s) => s.firmware.installPybricksDialog);
|
||||
const dispatch = useDispatch();
|
||||
const [hubType, setHubType] = useState(defaultHubType);
|
||||
const [hubName, setHubName] = useState('');
|
||||
const [includeProgram, setIncludeProgram] = useState(false);
|
||||
const [licenseAccepted, setLicenseAccepted] = useState(false);
|
||||
const { data } = useFirmware(hubType);
|
||||
const i18n = useI18n();
|
||||
|
||||
return (
|
||||
<MultistepDialog
|
||||
title={i18n.translate(I18nId.Title)}
|
||||
isOpen={isOpen}
|
||||
onClose={() => dispatch(firmwareInstallPybricksDialogCancel())}
|
||||
finalButtonProps={{
|
||||
text: i18n.translate(I18nId.FlashFirmwareButtonLabel),
|
||||
onClick: () =>
|
||||
dispatch(
|
||||
firmwareInstallPybricksDialogAccept(
|
||||
data?.firmwareZip ?? new ArrayBuffer(0),
|
||||
undefined,
|
||||
hubName,
|
||||
),
|
||||
),
|
||||
}}
|
||||
>
|
||||
<DialogStep
|
||||
id="hub"
|
||||
title={i18n.translate(I18nId.SelectHubPanelTitle)}
|
||||
panel={<SelectHubPanel hubType={hubType} onChange={setHubType} />}
|
||||
nextButtonProps={{ text: i18n.translate(I18nId.NextButtonLabel) }}
|
||||
/>
|
||||
<DialogStep
|
||||
id="license"
|
||||
title={i18n.translate(I18nId.LicensePanelTitle)}
|
||||
panel={
|
||||
<AcceptLicensePanel
|
||||
hubType={hubType}
|
||||
licenseAccepted={licenseAccepted}
|
||||
onLicenseAcceptedChanged={setLicenseAccepted}
|
||||
/>
|
||||
}
|
||||
backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }}
|
||||
nextButtonProps={{
|
||||
disabled: !licenseAccepted,
|
||||
text: i18n.translate(I18nId.NextButtonLabel),
|
||||
}}
|
||||
/>
|
||||
<DialogStep
|
||||
id="options"
|
||||
title={i18n.translate(I18nId.OptionsPanelTitle)}
|
||||
panel={
|
||||
<ConfigureOptionsPanel
|
||||
hubType={hubType}
|
||||
hubName={hubName}
|
||||
includeProgram={includeProgram}
|
||||
onChangeHubName={setHubName}
|
||||
onChangeIncludeProgram={setIncludeProgram}
|
||||
/>
|
||||
}
|
||||
backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }}
|
||||
nextButtonProps={{ text: i18n.translate(I18nId.NextButtonLabel) }}
|
||||
/>
|
||||
<DialogStep
|
||||
id="bootloader"
|
||||
title={i18n.translate(I18nId.BootloaderPanelTitle)}
|
||||
panel={<BootloaderModePanel hubType={hubType} />}
|
||||
backButtonProps={{ text: i18n.translate(I18nId.BackButtonLabel) }}
|
||||
/>
|
||||
</MultistepDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { createAction } from '../../actions';
|
||||
|
||||
/** Actions that request the install Pybricks firmware dialog to be shown. */
|
||||
export const firmwareInstallPybricksDialogShow = createAction(() => ({
|
||||
type: 'firmware.installPybricksDialog.action.show',
|
||||
}));
|
||||
|
||||
/** Actions that indicates the user accepted the install Pybricks firmware dialog. */
|
||||
export const firmwareInstallPybricksDialogAccept = createAction(
|
||||
(firmwareZip: ArrayBuffer, customProgram: string | undefined, hubName: string) => ({
|
||||
type: 'firmware.installPybricksDialog.action.accept',
|
||||
firmwareZip,
|
||||
customProgram,
|
||||
hubName,
|
||||
}),
|
||||
);
|
||||
|
||||
/** Actions that indicates the user canceled the install Pybricks firmware dialog. */
|
||||
export const firmwareInstallPybricksDialogCancel = createAction(() => ({
|
||||
type: 'firmware.installPybricksDialog.action.cancel',
|
||||
}));
|
||||
@@ -0,0 +1,128 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
// based on https://usehooks-ts.com/react-hook/use-fetch
|
||||
|
||||
import { FirmwareReader } 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';
|
||||
import { useEffect, useReducer, useRef } from 'react';
|
||||
import { Hub } from '../../components/hubPicker';
|
||||
|
||||
type FirmwareData = {
|
||||
firmwareZip: ArrayBuffer;
|
||||
licenseText: string;
|
||||
};
|
||||
|
||||
interface State {
|
||||
/** The firmware.zip data or undefined if `fetch()` is not complete or on error. */
|
||||
data?: FirmwareData;
|
||||
/** Undefined `fetch()` is not complete yet or was successful, otherwise the error. */
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
type Cache = { [url: string]: FirmwareData };
|
||||
|
||||
// discriminated union type
|
||||
type Action =
|
||||
| { type: 'loading' }
|
||||
| { type: 'fetched'; payload: FirmwareData }
|
||||
| { type: 'error'; payload: Error };
|
||||
|
||||
const firmwareZipMap = new Map<Hub, string>([
|
||||
[Hub.City, cityHubZip],
|
||||
[Hub.Technic, technicHubZip],
|
||||
[Hub.Move, moveHubZip],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Gets Pybricks firmware .zip file for the specified hub type.
|
||||
* @param hubType The hub type.
|
||||
* @returns The current state.
|
||||
*/
|
||||
export function useFirmware(hubType: Hub): State {
|
||||
const url = firmwareZipMap.get(hubType);
|
||||
const cache = useRef<Cache>({});
|
||||
|
||||
// Used to prevent state update if the component is unmounted
|
||||
const cancelRequest = useRef<boolean>(false);
|
||||
|
||||
const initialState: State = {
|
||||
error: undefined,
|
||||
data: undefined,
|
||||
};
|
||||
|
||||
// Keep state logic separated
|
||||
const fetchReducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'loading':
|
||||
return { ...initialState };
|
||||
case 'fetched':
|
||||
return { ...initialState, data: action.payload };
|
||||
case 'error':
|
||||
return { ...initialState, error: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const [state, dispatch] = useReducer(fetchReducer, initialState);
|
||||
|
||||
useEffect(() => {
|
||||
// Do nothing if the url is not given
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelRequest.current = false;
|
||||
|
||||
const fetchData = async () => {
|
||||
dispatch({ type: 'loading' });
|
||||
|
||||
// If a cache exists for this url, return it
|
||||
if (cache.current[url]) {
|
||||
dispatch({ type: 'fetched', payload: cache.current[url] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
|
||||
const firmwareZip = await response.arrayBuffer();
|
||||
const reader = await FirmwareReader.load(firmwareZip);
|
||||
const licenseText = await reader.readReadMeOss();
|
||||
const data = { firmwareZip, licenseText };
|
||||
|
||||
cache.current[url] = data;
|
||||
if (cancelRequest.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: 'fetched', payload: data });
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (cancelRequest.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: 'error', payload: error as Error });
|
||||
}
|
||||
};
|
||||
|
||||
void fetchData();
|
||||
|
||||
// Use the cleanup function for avoiding a possible
|
||||
// state update after the component was unmounted
|
||||
return () => {
|
||||
cancelRequest.current = true;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
return state;
|
||||
}
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020-2022 The Pybricks Authors
|
||||
// Copyright (c) 2021-2022 The Pybricks Authors
|
||||
|
||||
import { lookup } from '../../../../test';
|
||||
import { lookup } from '../../../test';
|
||||
import { I18nId } from './i18n';
|
||||
import en from './translations/en.json';
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021-2022 The Pybricks Authors
|
||||
//
|
||||
// Settings translation keys.
|
||||
|
||||
import { I18n, useI18n as useShopifyI18n } from '@shopify/react-i18n';
|
||||
|
||||
export function useI18n(): I18n {
|
||||
// istanbul ignore next: babel-loader rewrites this line
|
||||
const [i18n] = useShopifyI18n();
|
||||
return i18n;
|
||||
}
|
||||
|
||||
export enum I18nId {
|
||||
Title = 'title',
|
||||
SelectHubPanelTitle = 'selectHubPanel.title',
|
||||
SelectHubPanelMessage = 'selectHubPanel.message',
|
||||
SelectHubPanelNotOnListButtonLabel = 'selectHubPanel.notOnListButton.label',
|
||||
SelectHubPanelNotOnListButtonInfoMindstormsTitle = 'selectHubPanel.notOnListButton.info.mindstorms.title',
|
||||
SelectHubPanelNotOnListButtonInfoMindstormsRcx = 'selectHubPanel.notOnListButton.info.mindstorms.rcx',
|
||||
SelectHubPanelNotOnListButtonInfoMindstormsNxt = 'selectHubPanel.notOnListButton.info.mindstorms.nxt',
|
||||
SelectHubPanelNotOnListButtonInfoMindstormsEv3 = 'selectHubPanel.notOnListButton.info.mindstorms.ev3',
|
||||
SelectHubPanelNotOnListButtonInfoPoweredUpTitle = 'selectHubPanel.notOnListButton.info.poweredUp.title',
|
||||
SelectHubPanelNotOnListButtonInfoPoweredUpWedo2 = 'selectHubPanel.notOnListButton.info.poweredUp.wedo2',
|
||||
SelectHubPanelNotOnListButtonInfoPoweredUpDuploTrain = 'selectHubPanel.notOnListButton.info.poweredUp.duploTrain',
|
||||
SelectHubPanelNotOnListButtonInfoPoweredUpMario = 'selectHubPanel.notOnListButton.info.poweredUp.mario',
|
||||
SelectHubPanelNotOnListButtonInfoPoweredUpFootnote = 'selectHubPanel.notOnListButton.info.poweredUp.footnote',
|
||||
LicensePanelTitle = 'licensePanel.title',
|
||||
LicensePanelLicenseTextError = 'licensePanel.licenseText.error',
|
||||
LicensePanelAcceptCheckboxLabel = 'licensePanel.acceptCheckbox.label',
|
||||
OptionsPanelTitle = 'optionsPanel.title',
|
||||
OptionsPanelHubNameLabel = 'optionsPanel.hubName.label',
|
||||
OptionsPanelHubNameLabelInfo = 'optionsPanel.hubName.labelInfo',
|
||||
OptionsPanelHubNameHelp = 'optionsPanel.hubName.help',
|
||||
OptionsPanelHubNameError = 'optionsPanel.hubName.error',
|
||||
OptionsPanelCustomMainLabel = 'optionsPanel.customMain.label',
|
||||
OptionsPanelCustomMainLabelInfo = 'optionsPanel.customMain.labelInfo',
|
||||
OptionsPanelCustomMainNotApplicableMessage = 'optionsPanel.customMain.notApplicable.message',
|
||||
OptionsPanelCustomMainIncludeCurrentProgramLabel = 'optionsPanel.customMain.includeCurrentProgram.label',
|
||||
OptionsPanelCustomMainIncludeCurrentProgramHelp = 'optionsPanel.customMain.includeCurrentProgram.help',
|
||||
BootloaderPanelTitle = 'bootloaderPanel.title',
|
||||
BootloaderPanelInstruction1 = 'bootloaderPanel.instruction1',
|
||||
BootloaderPanelButtonBluetooth = 'bootloaderPanel.button.bluetooth',
|
||||
BootloaderPanelButtonPower = 'bootloaderPanel.button.power',
|
||||
BootloaderPanelLightBluetooth = 'bootloaderPanel.light.bluetooth',
|
||||
BootloaderPanelLightStatus = 'bootloaderPanel.light.status',
|
||||
BootloaderPanelLightPatternBluetooth = 'bootloaderPanel.lightPattern.bluetooth',
|
||||
BootloaderPanelLightPatternStatus = 'bootloaderPanel.lightPattern.status',
|
||||
BootloaderPanelStepDisconnectUsb = 'bootloaderPanel.step.disconnectUsb',
|
||||
BootloaderPanelStepPowerOff = 'bootloaderPanel.step.powerOff',
|
||||
BootloaderPanelStepDisconnectIo = 'bootloaderPanel.step.disconnectIo',
|
||||
BootloaderPanelStepHoldButton = 'bootloaderPanel.step.holdButton',
|
||||
BootloaderPanelStepConnectUsb = 'bootloaderPanel.step.connectUsb',
|
||||
BootloaderPanelStepWaitForLight = 'bootloaderPanel.step.waitForLight',
|
||||
BootloaderPanelStepReleaseButton = 'bootloaderPanel.step.releaseButton',
|
||||
BootloaderPanelStepKeepHolding = 'bootloaderPanel.step.keepHolding',
|
||||
BootloaderPanelInstruction2 = 'bootloaderPanel.instruction2',
|
||||
NextButtonLabel = 'nextButton.label',
|
||||
BackButtonLabel = 'backButton.label',
|
||||
FlashFirmwareButtonLabel = 'flashFirmwareButton.label',
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Validates the hub name.
|
||||
* @param hubName The hub name.
|
||||
* @returns True if the name if valid, otherwise false.
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
.pb-firmware-installPybricksDialog {
|
||||
&-body {
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
&-license {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: inherit;
|
||||
|
||||
&-text {
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { Reducer, combineReducers } from '@reduxjs/toolkit';
|
||||
import {
|
||||
firmwareInstallPybricksDialogAccept,
|
||||
firmwareInstallPybricksDialogCancel,
|
||||
firmwareInstallPybricksDialogShow,
|
||||
} from './actions';
|
||||
|
||||
/** Controls the flash Pybricks firmware dialog open state. */
|
||||
const isOpen: Reducer<boolean> = (state = false, action) => {
|
||||
if (firmwareInstallPybricksDialogShow.matches(action)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (firmwareInstallPybricksDialogAccept.matches(action)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (firmwareInstallPybricksDialogCancel.matches(action)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export default combineReducers({ isOpen });
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"title": "Install Pybricks Firmware",
|
||||
"selectHubPanel": {
|
||||
"title": "Select hub type",
|
||||
"message": "Which kind of hub do you want to use?",
|
||||
"notOnListButton": {
|
||||
"label": "My hub is not in the list.",
|
||||
"info": {
|
||||
"mindstorms": {
|
||||
"title": "MINDSTORMS Programmable Bricks",
|
||||
"rcx": "RCX - not enough memory to run Pybricks",
|
||||
"nxt": "NXT - maybe some day",
|
||||
"ev3": "EV3 - supported using VS Code instead of Pybricks Code"
|
||||
},
|
||||
"poweredUp": {
|
||||
"title": "Unsupported Powered Up Hubs",
|
||||
"wedo2": "WeDo 2.0 Smart hub",
|
||||
"duploTrain": "Duplo Train hub",
|
||||
"mario": "Mario/Luigi/Peach",
|
||||
"footnote": "firmware cannot be updated"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"licensePanel": {
|
||||
"title": "Accept licenses",
|
||||
"licenseText": {
|
||||
"error": "There was a problem while getting the firmware file."
|
||||
},
|
||||
"acceptCheckbox": {
|
||||
"label": "I have read and agree to the license terms and conditions."
|
||||
}
|
||||
},
|
||||
"optionsPanel": {
|
||||
"title": "Configure options",
|
||||
"hubName": {
|
||||
"label": "Hub name",
|
||||
"labelInfo": "(optional)",
|
||||
"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."
|
||||
},
|
||||
"customMain": {
|
||||
"label": "Custom program",
|
||||
"labelInfo": "(optional)",
|
||||
"notApplicable": {
|
||||
"message": "This hub has external flash memory so including a custom program when flashing firmware is not needed."
|
||||
},
|
||||
"includeCurrentProgram": {
|
||||
"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}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bootloaderPanel": {
|
||||
"title": "Place hub in bootloader mode",
|
||||
"instruction1": "To flash the firmware, the hub must be placed in bootloader mode. Follow the steps below to do this:",
|
||||
"button": {
|
||||
"bluetooth": "Bluetooth button",
|
||||
"power": "power button"
|
||||
},
|
||||
"light": {
|
||||
"bluetooth": "Bluetooth light",
|
||||
"status": "hub status light"
|
||||
},
|
||||
"lightPattern": {
|
||||
"bluetooth": "pink-green-blue-off",
|
||||
"status": "light purple"
|
||||
},
|
||||
"step": {
|
||||
"disconnectUsb": "Disconnect the USB cable from the hub.",
|
||||
"powerOff": "Turn off the hub.",
|
||||
"disconnectIo": "Disconnect all motors and sensors from the I/O ports on the hub.",
|
||||
"holdButton": "Press and hold the {button} on the hub.",
|
||||
"connectUsb": "Connect the USB cable.",
|
||||
"waitForLight": "Keep holding the {button} and wait for the {light} to start flashing {lightPattern}. This takes about 5 seconds.",
|
||||
"releaseButton": "Release the {button}",
|
||||
"keepHolding": "Keep holding the {button}."
|
||||
},
|
||||
"instruction2": "Then click the {flashFirmware} button below to connect to the hub and flash the firmware."
|
||||
},
|
||||
"backButton": {
|
||||
"label": "Back"
|
||||
},
|
||||
"nextButton": {
|
||||
"label": "Next"
|
||||
},
|
||||
"flashFirmwareButton": {
|
||||
"label": "Flash Firmware"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021 The Pybricks Authors
|
||||
// Copyright (c) 2021-2022 The Pybricks Authors
|
||||
|
||||
import { AnyAction } from 'redux';
|
||||
import {
|
||||
@@ -17,6 +17,9 @@ test('initial state', () => {
|
||||
expect(reducers(undefined, {} as AnyAction)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"flashing": false,
|
||||
"installPybricksDialog": Object {
|
||||
"isOpen": false,
|
||||
},
|
||||
"progress": null,
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { Reducer, combineReducers } from 'redux';
|
||||
import { didFailToFinish, didFinish, didProgress, didStart } from './actions';
|
||||
import installPybricksDialog from './installPybricksDialog/reducers';
|
||||
|
||||
const flashing: Reducer<boolean> = (state = false, action) => {
|
||||
if (didStart.matches(action)) {
|
||||
@@ -28,4 +29,4 @@ const progress: Reducer<number | null> = (state = null, action) => {
|
||||
return state;
|
||||
};
|
||||
|
||||
export default combineReducers({ flashing, progress });
|
||||
export default combineReducers({ installPybricksDialog, flashing, progress });
|
||||
|
||||
@@ -62,8 +62,14 @@ import {
|
||||
didFinish,
|
||||
didProgress,
|
||||
didStart,
|
||||
firmwareInstallPybricks,
|
||||
flashFirmware,
|
||||
} from './actions';
|
||||
import {
|
||||
firmwareInstallPybricksDialogAccept,
|
||||
firmwareInstallPybricksDialogCancel,
|
||||
firmwareInstallPybricksDialogShow,
|
||||
} from './installPybricksDialog/actions';
|
||||
|
||||
const firmwareZipMap = new Map<HubType, string>([
|
||||
[HubType.CityHub, cityHubZip],
|
||||
@@ -477,6 +483,23 @@ function* handleFlashFirmware(action: ReturnType<typeof flashFirmware>): Generat
|
||||
}
|
||||
}
|
||||
|
||||
function* handleInstallPybricks(): Generator {
|
||||
yield* put(firmwareInstallPybricksDialogShow());
|
||||
const { accepted, canceled } = yield* race({
|
||||
accepted: take(firmwareInstallPybricksDialogAccept),
|
||||
canceled: take(firmwareInstallPybricksDialogCancel),
|
||||
});
|
||||
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
defined(accepted);
|
||||
|
||||
yield* put(flashFirmware(accepted.firmwareZip, false, accepted.hubName));
|
||||
}
|
||||
|
||||
export default function* (): Generator {
|
||||
yield* takeEvery(flashFirmware, handleFlashFirmware);
|
||||
yield* takeEvery(firmwareInstallPybricks, handleInstallPybricks);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -14,14 +14,6 @@ describe('toolbar', () => {
|
||||
expect(runButton).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have flash button', () => {
|
||||
const [, toolbar] = testRender(<Toolbar />);
|
||||
|
||||
const runButton = toolbar.getByRole('button', { name: 'Flash' });
|
||||
|
||||
expect(runButton).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have run button', () => {
|
||||
const [, toolbar] = testRender(<Toolbar />);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import React from 'react';
|
||||
import { useId } from 'react-aria';
|
||||
import { Toolbar as UtilsToolbar } from '../components/toolbar/Toolbar';
|
||||
import BluetoothButton from './buttons/bluetooth/BluetoothButton';
|
||||
import FlashButton from './buttons/flash/FlashButton';
|
||||
import ReplButton from './buttons/repl/ReplButton';
|
||||
import RunButton from './buttons/run/RunButton';
|
||||
import StopButton from './buttons/stop/StopButton';
|
||||
@@ -23,7 +22,6 @@ const Toolbar: React.VFC = () => {
|
||||
return (
|
||||
<UtilsToolbar className="pb-toolbar" firstFocusableItemId={flashButtonId}>
|
||||
<ButtonGroup className="pb-toolbar-group pb-align-left">
|
||||
<FlashButton id={flashButtonId} />
|
||||
<BluetoothButton id={bluetoothButtonId} />
|
||||
</ButtonGroup>
|
||||
<ButtonGroup className="pb-toolbar-group pb-align-left">
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg width="50" height="50" version="1.1" viewBox="0 0 26.458 26.458" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="linearGradient5199" x1="76.89" x2="84.213" y1="96.73" y2="96.73" gradientTransform="matrix(1.2729 0 0 1.2729 -22.91 -26.356)" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/></linearGradient></defs><g transform="translate(0 -270.54)"><g transform="matrix(1.5713 0 0 1.5713 -114.97 131.36)"><path d="m75.463 99.915 7.7607-6.4212-3.7847-3.1468v12.779l3.8059-3.3807-7.8564-6.4637" fill="none" stroke="url(#linearGradient5199)" stroke-width="1.3471"/></g></g></svg>
|
||||
<svg width="50" height="50" version="1.1" viewBox="0 0 26.458 26.458" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="linearGradient5199" x1="76.89" x2="84.213" y1="96.73" y2="96.73" gradientTransform="matrix(1.2729,0,0,1.2729,-22.91,-26.356)" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" offset="0"/></linearGradient></defs><g transform="translate(3.0907 -270.54)"><g transform="matrix(1.5713,0,0,1.5713,-114.97,131.36)"><path d="m75.463 99.915 7.7607-6.4212-3.7847-3.1468v12.779l3.8059-3.3807-7.8564-6.4637" fill="none" stroke="url(#linearGradient5199)" stroke-width="1.3471"/></g></g></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 655 B After Width: | Height: | Size: 617 B |
@@ -1,20 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { testRender } from '../../../../test';
|
||||
import { flashFirmware } from '../../../firmware/actions';
|
||||
import FlashButton from './FlashButton';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('should dispatch action when clicked', async () => {
|
||||
const [user, button, dispatch] = testRender(<FlashButton id="test-flash-button" />);
|
||||
|
||||
await user.click(button.getByRole('button', { name: 'Flash' }));
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(flashFirmware(null, false, ''));
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020-2022 The Pybricks Authors
|
||||
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { BleConnectionState } from '../../../ble/reducers';
|
||||
import { flashFirmware } from '../../../firmware/actions';
|
||||
import { BootloaderConnectionState } from '../../../lwp3-bootloader/reducers';
|
||||
import * as notificationActions from '../../../notifications/actions';
|
||||
import { useSelector } from '../../../reducers';
|
||||
import {
|
||||
useSettingFlashCurrentProgram,
|
||||
useSettingHubName,
|
||||
} from '../../../settings/hooks';
|
||||
import OpenFileButton, { OpenFileButtonProps } from '../../../toolbar/OpenFileButton';
|
||||
import { I18nId, useI18n } from './i18n';
|
||||
import icon from './icon.svg';
|
||||
|
||||
type FlashButtonProps = Pick<OpenFileButtonProps, 'id'>;
|
||||
|
||||
const FlashButton: React.VoidFunctionComponent<FlashButtonProps> = ({ id }) => {
|
||||
const bootloaderConnection = useSelector((s) => s.bootloader.connection);
|
||||
const bleConnection = useSelector((s) => s.ble.connection);
|
||||
const flashing = useSelector((s) => s.firmware.flashing);
|
||||
const progress = useSelector((s) => s.firmware.progress);
|
||||
const [isSettingFlashCurrentProgramEnabled] = useSettingFlashCurrentProgram();
|
||||
const { hubName } = useSettingHubName();
|
||||
|
||||
const i18n = useI18n();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<OpenFileButton
|
||||
id={id}
|
||||
label={i18n.translate(I18nId.Label)}
|
||||
mimeType="application/zip"
|
||||
fileExtension=".zip"
|
||||
icon={icon}
|
||||
tooltip={
|
||||
progress
|
||||
? i18n.translate(I18nId.TooltipProgress, {
|
||||
percent: i18n.formatPercentage(progress),
|
||||
})
|
||||
: i18n.translate(I18nId.TooltipAction)
|
||||
}
|
||||
enabled={
|
||||
bootloaderConnection === BootloaderConnectionState.Disconnected &&
|
||||
bleConnection === BleConnectionState.Disconnected
|
||||
}
|
||||
showProgress={flashing}
|
||||
progress={progress === null ? undefined : progress}
|
||||
onFile={(data) =>
|
||||
dispatch(
|
||||
flashFirmware(data, isSettingFlashCurrentProgramEnabled, hubName),
|
||||
)
|
||||
}
|
||||
onReject={(file) =>
|
||||
dispatch(
|
||||
notificationActions.add(
|
||||
'error',
|
||||
`'${file.name}' is not a valid firmware file.`,
|
||||
),
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
flashFirmware(null, isSettingFlashCurrentProgramEnabled, hubName),
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlashButton;
|
||||
@@ -1,16 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020-2022 The Pybricks Authors
|
||||
|
||||
import { I18n, useI18n as useShopifyI18n } from '@shopify/react-i18n';
|
||||
|
||||
export function useI18n(): I18n {
|
||||
// istanbul ignore next: babel-loader rewrites this line
|
||||
const [i18n] = useShopifyI18n();
|
||||
return i18n;
|
||||
}
|
||||
|
||||
export enum I18nId {
|
||||
Label = 'label',
|
||||
TooltipAction = 'tooltip.action',
|
||||
TooltipProgress = 'tooltip.progress',
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg width="50" height="50" version="1.1" viewBox="0 0 26.458 26.458" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient id="light-circle-fill" gradientTransform="matrix(.041492 0 0 .041492 1.7417 549.61)"><stop stop-color="#fff" offset="0"/></linearGradient><linearGradient id="linearGradient5187" x1="170.49" x2="171.55" y1="91.975" y2="91.975" gradientTransform="matrix(2.2771 0 0 2.2771 -375.89 64.429)" gradientUnits="userSpaceOnUse" xlink:href="#light-circle-fill"/></defs><g transform="translate(0 -270.54)"><path transform="matrix(0 2.2771 -2.2771 0 242.26 191.39)" d="m39.757 100.45-2.9978 1.7308v-3.4615l1.4989 0.86538z" fill="url(#light-circle-fill)"/><path d="m13.537 271.93v3.8731" fill="#4e4e4e" stroke="url(#linearGradient5187)" stroke-width="2.1167"/><g fill="url(#light-circle-fill)"><rect x="1.2749" y="284.17" width="24.017" height="10.586" rx="0" ry="0"/><rect x="3.4315" y="283.18" width="2.7715" height="10.586" rx="0" ry="0"/><rect x="9.0755" y="283.18" width="2.7715" height="10.586" rx="0" ry="0"/><rect x="14.719" y="283.18" width="2.7715" height="10.586" rx="0" ry="0"/><rect x="20.364" y="283.18" width="2.7715" height="10.586" rx="0" ry="0"/></g></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"label": "Flash",
|
||||
"tooltip": {
|
||||
"action": "Install Pybricks firmware",
|
||||
"progress": "Flashing… {percent}"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user