mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-14 10:35:11 +00:00
Merge pull request #1114 from pybricks/dlech
restore custom firmware flashing
This commit is contained in:
@@ -4,6 +4,9 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Added feature to install custom firmware from file ([pybricks-code#1020]).
|
||||
|
||||
### Fixed
|
||||
- Fixed run button enabled when no file open ([support#691]).
|
||||
- Fixed flash firmware dialog not showing when settings not open ([support#694]).
|
||||
@@ -11,6 +14,7 @@
|
||||
- Fixed imports with invalid file name silently ignored ([support#717]).
|
||||
|
||||
[pybricks-code#1011]: https://github.com/pybricks/pybricks-code/issues/1011
|
||||
[pybricks-code#1020]: https://github.com/pybricks/pybricks-code/issues/1020
|
||||
[support#691]: https://github.com/pybricks/support/issues/691
|
||||
[support#694]: https://github.com/pybricks/support/issues/694
|
||||
[support#717]: https://github.com/pybricks/support/issues/717
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { IToastProps } from '@blueprintjs/core';
|
||||
import { ToastProps } from '@blueprintjs/core';
|
||||
import alerts from './alerts/alerts';
|
||||
import ble from './ble/alerts';
|
||||
import explorer from './explorer/alerts';
|
||||
@@ -79,7 +79,7 @@ export function getAlertProps<D extends AlertDomain, S extends AlertSpecific<D>>
|
||||
specific: S,
|
||||
onAlert: AlertCallback<D, S>,
|
||||
props: AlertProps<D, S>,
|
||||
): IToastProps {
|
||||
): ToastProps {
|
||||
const create = alertDomains[domain][specific] as unknown as CreateToast<
|
||||
Record<string, unknown> | never,
|
||||
string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { IToastOptions, IToastProps, IToaster } from '@blueprintjs/core';
|
||||
import { IToastOptions, ToastProps, ToasterInstance } from '@blueprintjs/core';
|
||||
import { waitFor } from '@testing-library/dom';
|
||||
import { AsyncSaga } from '../../test';
|
||||
import { alertsDidShowAlert, alertsShowAlert } from './actions';
|
||||
@@ -11,14 +11,14 @@ afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
class TestToaster implements IToaster {
|
||||
class TestToaster implements ToasterInstance {
|
||||
private toasts = new Array<IToastOptions>();
|
||||
|
||||
public getToasts(): IToastOptions[] {
|
||||
return this.toasts;
|
||||
}
|
||||
|
||||
public show(props: IToastProps, key?: string): string {
|
||||
public show(props: ToastProps, key?: string): string {
|
||||
if (!key) {
|
||||
throw new Error('key is required!');
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,17 +1,17 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
|
||||
import { IToaster } from '@blueprintjs/core';
|
||||
import { ToasterInstance } from '@blueprintjs/core';
|
||||
import { eventChannel } from 'redux-saga';
|
||||
import { delay, getContext, put, take, takeEvery } from 'typed-redux-saga/macro';
|
||||
import { getAlertProps } from '../alerts';
|
||||
import { alertsDidShowAlert, alertsShowAlert } from './actions';
|
||||
|
||||
export type AlertsSagaContext = { toaster: IToaster };
|
||||
export type AlertsSagaContext = { toaster: ToasterInstance };
|
||||
|
||||
/** Shows an alert to the user and avoids duplicate alerts. */
|
||||
function* handleShowAlert(action: ReturnType<typeof alertsShowAlert>): Generator {
|
||||
const toaster = yield* getContext<IToaster>('toaster');
|
||||
const toaster = yield* getContext<ToasterInstance>('toaster');
|
||||
|
||||
const key = `${action.domain}.${action.specific}.${JSON.stringify(action.props)}`;
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
Checkbox,
|
||||
Classes,
|
||||
Code,
|
||||
Collapse,
|
||||
ControlGroup,
|
||||
DialogStep,
|
||||
FormGroup,
|
||||
IRef,
|
||||
Icon,
|
||||
InputGroup,
|
||||
Intent,
|
||||
@@ -24,9 +24,14 @@ import {
|
||||
} from '@blueprintjs/core';
|
||||
import { Classes as Classes2, Popover2 } from '@blueprintjs/popover2';
|
||||
import { Select2 } from '@blueprintjs/select';
|
||||
import { FirmwareMetadata, HubType } from '@pybricks/firmware';
|
||||
import { fileOpen } from 'browser-fs-access';
|
||||
import classNames from 'classnames';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLocalStorage } from 'usehooks-ts';
|
||||
import { alertsShowAlert } from '../../alerts/actions';
|
||||
import {
|
||||
appName,
|
||||
pybricksUsbDfuWindowsDriverInstallUrl,
|
||||
@@ -45,13 +50,14 @@ import { useHubPickerSelectedHub } from '../../components/hubPicker/hooks';
|
||||
import { FileMetadata } from '../../fileStorage';
|
||||
import { useFileStorageMetadata } from '../../fileStorage/hooks';
|
||||
import { useSelector } from '../../reducers';
|
||||
import { ensureError } from '../../utils';
|
||||
import ExternalLinkIcon from '../../utils/ExternalLinkIcon';
|
||||
import { isLinux, isWindows } from '../../utils/os';
|
||||
import {
|
||||
firmwareInstallPybricksDialogAccept,
|
||||
firmwareInstallPybricksDialogCancel,
|
||||
} from './actions';
|
||||
import { useFirmware } from './hooks';
|
||||
import { useCustomFirmware, useFirmware } from './hooks';
|
||||
import { useI18n } from './i18n';
|
||||
import { validateHubName } from '.';
|
||||
|
||||
@@ -60,82 +66,246 @@ const dialogBody = classNames(
|
||||
'pb-firmware-installPybricksDialog-body',
|
||||
);
|
||||
|
||||
const SelectHubPanel: React.VoidFunctionComponent = () => {
|
||||
/** Translates hub type from firmware metadata to local hub type. */
|
||||
function getHubTypeFromMetadata(
|
||||
metadata: FirmwareMetadata | undefined,
|
||||
fallback: Hub,
|
||||
): Hub {
|
||||
switch (metadata?.['device-id']) {
|
||||
case HubType.MoveHub:
|
||||
return Hub.Move;
|
||||
case HubType.CityHub:
|
||||
return Hub.City;
|
||||
case HubType.TechnicHub:
|
||||
return Hub.Technic;
|
||||
case HubType.PrimeHub:
|
||||
return Hub.Prime;
|
||||
case HubType.EssentialHub:
|
||||
return Hub.Essential;
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function getHubTypeNameFromMetadata(metadata: FirmwareMetadata | undefined): string {
|
||||
switch (metadata?.['device-id']) {
|
||||
case HubType.MoveHub:
|
||||
return 'BOOST Move Hub';
|
||||
case HubType.CityHub:
|
||||
return 'City Hub';
|
||||
case HubType.TechnicHub:
|
||||
return 'Technic Hub';
|
||||
case HubType.PrimeHub:
|
||||
return 'SPIKE Prime/MINDSTORMS Robot Inventor hub';
|
||||
case HubType.EssentialHub:
|
||||
return 'SPIKE Essential hub';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
const UnsupportedHubs: React.VoidFunctionComponent = () => {
|
||||
const i18n = useI18n();
|
||||
|
||||
return (
|
||||
<div className={dialogBody}>
|
||||
<p>{i18n.translate('selectHubPanel.message')}</p>
|
||||
<HubPicker />
|
||||
<Popover2
|
||||
popoverClassName={Classes2.POPOVER2_CONTENT_SIZING}
|
||||
placement="right-end"
|
||||
content={
|
||||
<div className={Classes.RUNNING_TEXT}>
|
||||
<h4>
|
||||
{i18n.translate(
|
||||
'selectHubPanel.notOnListButton.info.mindstorms.title',
|
||||
)}
|
||||
</h4>
|
||||
<ul>
|
||||
<li>
|
||||
{i18n.translate(
|
||||
'selectHubPanel.notOnListButton.info.mindstorms.rcx',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{i18n.translate(
|
||||
'selectHubPanel.notOnListButton.info.mindstorms.nxt',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{i18n.translate(
|
||||
'selectHubPanel.notOnListButton.info.mindstorms.ev3',
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
<h4>
|
||||
{i18n.translate(
|
||||
'selectHubPanel.notOnListButton.info.poweredUp.title',
|
||||
)}
|
||||
</h4>
|
||||
<ul>
|
||||
<li>
|
||||
{i18n.translate(
|
||||
'selectHubPanel.notOnListButton.info.poweredUp.wedo2',
|
||||
)}
|
||||
<em>*</em>
|
||||
</li>
|
||||
<li>
|
||||
{i18n.translate(
|
||||
'selectHubPanel.notOnListButton.info.poweredUp.duploTrain',
|
||||
)}
|
||||
<em>*</em>
|
||||
</li>
|
||||
<li>
|
||||
{i18n.translate(
|
||||
'selectHubPanel.notOnListButton.info.poweredUp.mario',
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
<div className={Classes.RUNNING_TEXT}>
|
||||
<h4>
|
||||
{i18n.translate('selectHubPanel.notOnListButton.info.mindstorms.title')}
|
||||
</h4>
|
||||
<ul>
|
||||
<li>
|
||||
{i18n.translate(
|
||||
'selectHubPanel.notOnListButton.info.mindstorms.rcx',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{i18n.translate(
|
||||
'selectHubPanel.notOnListButton.info.mindstorms.nxt',
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{i18n.translate(
|
||||
'selectHubPanel.notOnListButton.info.mindstorms.ev3',
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
<h4>
|
||||
{i18n.translate('selectHubPanel.notOnListButton.info.poweredUp.title')}
|
||||
</h4>
|
||||
<ul>
|
||||
<li>
|
||||
{i18n.translate(
|
||||
'selectHubPanel.notOnListButton.info.poweredUp.wedo2',
|
||||
)}
|
||||
<em>*</em>
|
||||
</li>
|
||||
<li>
|
||||
{i18n.translate(
|
||||
'selectHubPanel.notOnListButton.info.poweredUp.duploTrain',
|
||||
)}
|
||||
<em>*</em>
|
||||
</li>
|
||||
<li>
|
||||
{i18n.translate(
|
||||
'selectHubPanel.notOnListButton.info.poweredUp.mario',
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<em>
|
||||
*{' '}
|
||||
{i18n.translate(
|
||||
'selectHubPanel.notOnListButton.info.poweredUp.footnote',
|
||||
)}
|
||||
</em>
|
||||
</div>
|
||||
}
|
||||
renderTarget={({ isOpen: _isOpen, ref, ...targetProps }) => (
|
||||
<Button
|
||||
elementRef={ref as IRef<HTMLButtonElement>}
|
||||
{...targetProps}
|
||||
>
|
||||
{i18n.translate('selectHubPanel.notOnListButton.label')}
|
||||
</Button>
|
||||
<em>
|
||||
*{' '}
|
||||
{i18n.translate(
|
||||
'selectHubPanel.notOnListButton.info.poweredUp.footnote',
|
||||
)}
|
||||
/>
|
||||
</em>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SelectHubPanelProps = {
|
||||
customFirmwareZip: File | undefined;
|
||||
onCustomFirmwareZip: (firmwareZip: File | undefined) => void;
|
||||
};
|
||||
|
||||
const SelectHubPanel: React.VoidFunctionComponent<SelectHubPanelProps> = ({
|
||||
customFirmwareZip,
|
||||
onCustomFirmwareZip,
|
||||
}) => {
|
||||
const { isCustomFirmwareRequested, customFirmwareData } =
|
||||
useCustomFirmware(customFirmwareZip);
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useLocalStorage(
|
||||
'installPybricksDialog.isAdvancedOpen',
|
||||
false,
|
||||
);
|
||||
const i18n = useI18n();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
// should only be one file since multiple={false}
|
||||
acceptedFiles.forEach((f) => {
|
||||
onCustomFirmwareZip(f);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onClick = useCallback(async () => {
|
||||
try {
|
||||
const file = await fileOpen({
|
||||
id: 'customFirmware',
|
||||
mimeTypes: ['application/zip'],
|
||||
extensions: ['.zip'],
|
||||
// TODO: translate description
|
||||
description: 'Zip Files',
|
||||
excludeAcceptAllOption: true,
|
||||
startIn: 'downloads',
|
||||
});
|
||||
|
||||
onCustomFirmwareZip(file);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
// user cancelled, nothing to do
|
||||
} else {
|
||||
dispatch(
|
||||
alertsShowAlert('alerts', 'unexpectedError', {
|
||||
error: ensureError(err),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
accept: { 'application/zip': ['.zip'] },
|
||||
multiple: false,
|
||||
// react-dropzone doesn't allow full control of File System API, so we
|
||||
// implement our own using browser-fs-access instead.
|
||||
noClick: true,
|
||||
onDrop,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={dialogBody}>
|
||||
{isCustomFirmwareRequested ? (
|
||||
<>
|
||||
<p>{i18n.translate('selectHubPanel.customFirmware.message')}</p>
|
||||
<p>
|
||||
{i18n.translate('selectHubPanel.customFirmware.hubType', {
|
||||
hubTypeName: getHubTypeNameFromMetadata(
|
||||
customFirmwareData?.metadata,
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{i18n.translate(
|
||||
'selectHubPanel.customFirmware.firmwareVersion',
|
||||
{
|
||||
version:
|
||||
customFirmwareData?.metadata['firmware-version'],
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onCustomFirmwareZip(undefined);
|
||||
}}
|
||||
>
|
||||
{i18n.translate('selectHubPanel.customFirmware.clearButton')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>{i18n.translate('selectHubPanel.message')}</p>
|
||||
<HubPicker />
|
||||
<Popover2
|
||||
popoverClassName={Classes2.POPOVER2_CONTENT_SIZING}
|
||||
placement="right-end"
|
||||
content={<UnsupportedHubs />}
|
||||
renderTarget={({ isOpen: _isOpen, ref, ...targetProps }) => (
|
||||
<Button
|
||||
elementRef={ref as React.Ref<HTMLButtonElement>}
|
||||
{...targetProps}
|
||||
>
|
||||
{i18n.translate('selectHubPanel.notOnListButton.label')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="pb-firmware-installPybricksDialog-selectHub-advanced">
|
||||
<Button
|
||||
minimal={true}
|
||||
small={true}
|
||||
icon={isAdvancedOpen ? 'chevron-down' : 'chevron-right'}
|
||||
onClick={() => setIsAdvancedOpen((v) => !v)}
|
||||
>
|
||||
{i18n.translate('selectHubPanel.advanced.label')}
|
||||
</Button>
|
||||
<Collapse isOpen={isAdvancedOpen}>
|
||||
<div
|
||||
{...getRootProps({
|
||||
className: 'pb-dropzone-root',
|
||||
onClick,
|
||||
onKeyDown,
|
||||
})}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{i18n.translate(
|
||||
'selectHubPanel.advanced.customFirmwareDropzone.label',
|
||||
)}
|
||||
</div>
|
||||
</Collapse>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -143,27 +313,38 @@ const SelectHubPanel: React.VoidFunctionComponent = () => {
|
||||
type AcceptLicensePanelProps = {
|
||||
hubType: Hub;
|
||||
licenseAccepted: boolean;
|
||||
customFirmwareZip: File | undefined;
|
||||
onLicenseAcceptedChanged: (accepted: boolean) => void;
|
||||
};
|
||||
|
||||
const AcceptLicensePanel: React.VoidFunctionComponent<AcceptLicensePanelProps> = ({
|
||||
hubType,
|
||||
licenseAccepted,
|
||||
customFirmwareZip,
|
||||
onLicenseAcceptedChanged,
|
||||
}) => {
|
||||
const { data, error } = useFirmware(hubType);
|
||||
const { firmwareData, firmwareError } = useFirmware(hubType);
|
||||
const { isCustomFirmwareRequested, customFirmwareData, customFirmwareError } =
|
||||
useCustomFirmware(customFirmwareZip);
|
||||
const i18n = useI18n();
|
||||
|
||||
const selectedFirmwareData = isCustomFirmwareRequested
|
||||
? customFirmwareData
|
||||
: firmwareData;
|
||||
const selectedFirmwareError = isCustomFirmwareRequested
|
||||
? customFirmwareError
|
||||
: firmwareError;
|
||||
|
||||
return (
|
||||
<div className={dialogBody}>
|
||||
<div className="pb-firmware-installPybricksDialog-license-text">
|
||||
{data ? (
|
||||
<Pre>{data.licenseText}</Pre>
|
||||
{selectedFirmwareData ? (
|
||||
<Pre>{selectedFirmwareData.licenseText}</Pre>
|
||||
) : (
|
||||
<NonIdealState
|
||||
icon={error ? 'error' : <Spinner />}
|
||||
icon={selectedFirmwareError ? 'error' : <Spinner />}
|
||||
description={
|
||||
error
|
||||
selectedFirmwareError
|
||||
? i18n.translate('licensePanel.licenseText.error')
|
||||
: undefined
|
||||
}
|
||||
@@ -175,7 +356,7 @@ const AcceptLicensePanel: React.VoidFunctionComponent<AcceptLicensePanelProps> =
|
||||
label={i18n.translate('licensePanel.acceptCheckbox.label')}
|
||||
checked={licenseAccepted}
|
||||
onChange={(e) => onLicenseAcceptedChanged(e.currentTarget.checked)}
|
||||
disabled={!data}
|
||||
disabled={!selectedFirmwareData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -440,9 +621,19 @@ export const InstallPybricksDialog: React.VoidFunctionComponent = () => {
|
||||
const [selectedIncludeFile, setSelectedIncludeFile] = useState<FileMetadata>();
|
||||
const [licenseAccepted, setLicenseAccepted] = useState(false);
|
||||
const [hubType] = useHubPickerSelectedHub();
|
||||
const { data } = useFirmware(hubType);
|
||||
const { firmwareData } = useFirmware(hubType);
|
||||
const [customFirmwareZip, setCustomFirmwareZip] = useState<File>();
|
||||
const { isCustomFirmwareRequested, customFirmwareData } =
|
||||
useCustomFirmware(customFirmwareZip);
|
||||
const i18n = useI18n();
|
||||
|
||||
const selectedFirmwareData = isCustomFirmwareRequested
|
||||
? customFirmwareData
|
||||
: firmwareData;
|
||||
const selectedHubType = isCustomFirmwareRequested
|
||||
? getHubTypeFromMetadata(customFirmwareData?.metadata, hubType)
|
||||
: hubType;
|
||||
|
||||
return (
|
||||
<MultistepDialog
|
||||
title={i18n.translate('title')}
|
||||
@@ -453,8 +644,8 @@ export const InstallPybricksDialog: React.VoidFunctionComponent = () => {
|
||||
onClick: () =>
|
||||
dispatch(
|
||||
firmwareInstallPybricksDialogAccept(
|
||||
hubBootloaderType(hubType),
|
||||
data?.firmwareZip ?? new ArrayBuffer(0),
|
||||
hubBootloaderType(selectedHubType),
|
||||
selectedFirmwareData?.firmwareZip ?? new ArrayBuffer(0),
|
||||
selectedIncludeFile?.path,
|
||||
hubName,
|
||||
),
|
||||
@@ -464,7 +655,12 @@ export const InstallPybricksDialog: React.VoidFunctionComponent = () => {
|
||||
<DialogStep
|
||||
id="hub"
|
||||
title={i18n.translate('selectHubPanel.title')}
|
||||
panel={<SelectHubPanel />}
|
||||
panel={
|
||||
<SelectHubPanel
|
||||
customFirmwareZip={customFirmwareZip}
|
||||
onCustomFirmwareZip={setCustomFirmwareZip}
|
||||
/>
|
||||
}
|
||||
nextButtonProps={{ text: i18n.translate('nextButton.label') }}
|
||||
/>
|
||||
<DialogStep
|
||||
@@ -472,8 +668,9 @@ export const InstallPybricksDialog: React.VoidFunctionComponent = () => {
|
||||
title={i18n.translate('licensePanel.title')}
|
||||
panel={
|
||||
<AcceptLicensePanel
|
||||
hubType={hubType}
|
||||
hubType={selectedHubType}
|
||||
licenseAccepted={licenseAccepted}
|
||||
customFirmwareZip={customFirmwareZip}
|
||||
onLicenseAcceptedChanged={setLicenseAccepted}
|
||||
/>
|
||||
}
|
||||
@@ -488,7 +685,7 @@ export const InstallPybricksDialog: React.VoidFunctionComponent = () => {
|
||||
title={i18n.translate('optionsPanel.title')}
|
||||
panel={
|
||||
<ConfigureOptionsPanel
|
||||
hubType={hubType}
|
||||
hubType={selectedHubType}
|
||||
hubName={hubName}
|
||||
includeProgram={includeProgram}
|
||||
selectedIncludeFile={selectedIncludeFile}
|
||||
@@ -503,7 +700,7 @@ export const InstallPybricksDialog: React.VoidFunctionComponent = () => {
|
||||
<DialogStep
|
||||
id="bootloader"
|
||||
title={i18n.translate('bootloaderPanel.title')}
|
||||
panel={<BootloaderModePanel hubType={hubType} />}
|
||||
panel={<BootloaderModePanel hubType={selectedHubType} />}
|
||||
backButtonProps={{ text: i18n.translate('backButton.label') }}
|
||||
/>
|
||||
</MultistepDialog>
|
||||
|
||||
@@ -2,25 +2,30 @@
|
||||
// Copyright (c) 2022 The Pybricks Authors
|
||||
// based on https://usehooks-ts.com/react-hook/use-fetch
|
||||
|
||||
import { FirmwareReader } from '@pybricks/firmware';
|
||||
import { FirmwareMetadata, FirmwareReader } from '@pybricks/firmware';
|
||||
import cityHubZip from '@pybricks/firmware/build/cityhub.zip';
|
||||
import essentialHubZip from '@pybricks/firmware/build/essentialhub.zip';
|
||||
import moveHubZip from '@pybricks/firmware/build/movehub.zip';
|
||||
import primeHubZip from '@pybricks/firmware/build/primehub.zip';
|
||||
import technicHubZip from '@pybricks/firmware/build/technichub.zip';
|
||||
import { useEffect, useReducer, useRef } from 'react';
|
||||
import { useEffect, useMemo, useReducer, useRef } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useIsMounted } from 'usehooks-ts';
|
||||
import { alertsShowAlert } from '../../alerts/actions';
|
||||
import { Hub } from '../../components/hubPicker';
|
||||
import { ensureError } from '../../utils';
|
||||
|
||||
type FirmwareData = {
|
||||
firmwareZip: ArrayBuffer;
|
||||
licenseText: string;
|
||||
metadata: FirmwareMetadata;
|
||||
};
|
||||
|
||||
interface State {
|
||||
/** The firmware.zip data or undefined if `fetch()` is not complete or on error. */
|
||||
data?: FirmwareData;
|
||||
firmwareData?: FirmwareData;
|
||||
/** Undefined `fetch()` is not complete yet or was successful, otherwise the error. */
|
||||
error?: Error;
|
||||
firmwareError?: Error;
|
||||
}
|
||||
|
||||
type Cache = { [url: string]: FirmwareData };
|
||||
@@ -48,13 +53,11 @@ const firmwareZipMap = new Map<Hub, string>([
|
||||
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 isMounted = useIsMounted();
|
||||
|
||||
const initialState: State = {
|
||||
error: undefined,
|
||||
data: undefined,
|
||||
firmwareError: undefined,
|
||||
firmwareData: undefined,
|
||||
};
|
||||
|
||||
// Keep state logic separated
|
||||
@@ -63,9 +66,9 @@ export function useFirmware(hubType: Hub): State {
|
||||
case 'loading':
|
||||
return { ...initialState };
|
||||
case 'fetched':
|
||||
return { ...initialState, data: action.payload };
|
||||
return { ...initialState, firmwareData: action.payload };
|
||||
case 'error':
|
||||
return { ...initialState, error: action.payload };
|
||||
return { ...initialState, firmwareError: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -79,8 +82,6 @@ export function useFirmware(hubType: Hub): State {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelRequest.current = false;
|
||||
|
||||
const fetchData = async () => {
|
||||
dispatch({ type: 'loading' });
|
||||
|
||||
@@ -99,10 +100,12 @@ export function useFirmware(hubType: Hub): State {
|
||||
const firmwareZip = await response.arrayBuffer();
|
||||
const reader = await FirmwareReader.load(firmwareZip);
|
||||
const licenseText = await reader.readReadMeOss();
|
||||
const data = { firmwareZip, licenseText };
|
||||
const metadata = await reader.readMetadata();
|
||||
const data = { firmwareZip, licenseText, metadata };
|
||||
|
||||
cache.current[url] = data;
|
||||
if (cancelRequest.current) {
|
||||
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -112,22 +115,103 @@ export function useFirmware(hubType: Hub): State {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (cancelRequest.current) {
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: 'error', payload: error as Error });
|
||||
dispatch({ type: 'error', payload: ensureError(error) });
|
||||
}
|
||||
};
|
||||
|
||||
void fetchData();
|
||||
|
||||
// Use the cleanup function for avoiding a possible
|
||||
// state update after the component was unmounted
|
||||
return () => {
|
||||
cancelRequest.current = true;
|
||||
};
|
||||
}, [url]);
|
||||
}, [url, isMounted]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the data from the user-provided firmware file, if any.
|
||||
* @param zipFile The user-provided zip file.
|
||||
* @returns State consisting of unzipped data or error.
|
||||
*/
|
||||
export function useCustomFirmware(zipFile: File | undefined) {
|
||||
const reduxDispatch = useDispatch();
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const initialState: State = {
|
||||
firmwareError: undefined,
|
||||
firmwareData: undefined,
|
||||
};
|
||||
|
||||
// Keep state logic separated
|
||||
const fetchReducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'loading':
|
||||
return { ...initialState };
|
||||
case 'fetched':
|
||||
return { ...initialState, firmwareData: action.payload };
|
||||
case 'error':
|
||||
return { ...initialState, firmwareError: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const [state, dispatch] = useReducer(fetchReducer, initialState);
|
||||
|
||||
useEffect(() => {
|
||||
if (!zipFile) {
|
||||
dispatch({ type: 'loading' });
|
||||
return;
|
||||
}
|
||||
|
||||
// REVISIT: with no cache, we end up unzipping the same file multiple times.
|
||||
|
||||
const readFile = async () => {
|
||||
dispatch({ type: 'loading' });
|
||||
|
||||
try {
|
||||
const firmwareZip = await zipFile.arrayBuffer();
|
||||
const reader = await FirmwareReader.load(firmwareZip);
|
||||
const licenseText = await reader.readReadMeOss();
|
||||
const metadata = await reader.readMetadata();
|
||||
const data = {
|
||||
firmwareZip,
|
||||
licenseText,
|
||||
metadata,
|
||||
};
|
||||
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: 'fetched', payload: data });
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
if (!isMounted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const error = ensureError(err);
|
||||
dispatch({ type: 'error', payload: error });
|
||||
reduxDispatch(alertsShowAlert('alerts', 'unexpectedError', { error }));
|
||||
}
|
||||
};
|
||||
|
||||
readFile();
|
||||
}, [zipFile, isMounted]);
|
||||
|
||||
const isCustomFirmwareRequested = useMemo(
|
||||
() => state.firmwareData !== undefined,
|
||||
[state.firmwareData],
|
||||
);
|
||||
|
||||
return {
|
||||
isCustomFirmwareRequested,
|
||||
customFirmwareData: state.firmwareData,
|
||||
customFirmwareError: state.firmwareError,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
gap: bp.$pt-grid-size;
|
||||
}
|
||||
|
||||
&-selectHub {
|
||||
&-advanced {
|
||||
margin-top: auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&-license {
|
||||
&-text {
|
||||
flex-grow: 1;
|
||||
|
||||
@@ -20,6 +20,18 @@
|
||||
"footnote": "firmware cannot be updated"
|
||||
}
|
||||
}
|
||||
},
|
||||
"advanced": {
|
||||
"label": "Advanced",
|
||||
"customFirmwareDropzone": {
|
||||
"label": "Drop custom firmware .zip file here or click to browse."
|
||||
}
|
||||
},
|
||||
"customFirmware": {
|
||||
"message": "Custom firmware selected.",
|
||||
"hubType": "Hub Type: {hubTypeName}",
|
||||
"firmwareVersion": "Firmware Version: {version}",
|
||||
"clearButton": "Clear"
|
||||
}
|
||||
},
|
||||
"licensePanel": {
|
||||
|
||||
+22
-22
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021-2022 The Pybricks Authors
|
||||
|
||||
import { IToaster } from '@blueprintjs/core';
|
||||
import { ToasterInstance } from '@blueprintjs/core';
|
||||
import {
|
||||
FirmwareMetadata,
|
||||
FirmwareReaderError,
|
||||
@@ -81,7 +81,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -231,7 +231,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -281,7 +281,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -349,7 +349,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -413,7 +413,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -480,7 +480,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -540,7 +540,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -606,7 +606,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -689,7 +689,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -755,7 +755,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -854,7 +854,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -962,7 +962,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -1110,7 +1110,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -1256,7 +1256,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -1409,7 +1409,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -1457,7 +1457,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -1504,7 +1504,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -1565,7 +1565,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -1627,7 +1627,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -1692,7 +1692,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
@@ -1782,7 +1782,7 @@ describe('flashFirmware', () => {
|
||||
|
||||
const saga = new AsyncSaga(flashFirmware, {
|
||||
nextMessageId: createCountFunc(),
|
||||
toaster: mock<IToaster>(),
|
||||
toaster: mock<ToasterInstance>(),
|
||||
});
|
||||
|
||||
// saga is triggered by this action
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020-2022 The Pybricks Authors
|
||||
|
||||
import { IToaster } from '@blueprintjs/core';
|
||||
import { ToasterInstance } from '@blueprintjs/core';
|
||||
import {
|
||||
FirmwareReader,
|
||||
FirmwareReaderError,
|
||||
@@ -95,7 +95,7 @@ const firmwareZipMap = new Map<HubType, string>([
|
||||
* parent task).
|
||||
*/
|
||||
function* disconnectAndCancel(): SagaGenerator<void> {
|
||||
const toaster = yield* getContext<IToaster>('toaster');
|
||||
const toaster = yield* getContext<ToasterInstance>('toaster');
|
||||
|
||||
toaster.dismiss('firmware.ble.progress');
|
||||
|
||||
@@ -333,7 +333,7 @@ function* loadFirmware(
|
||||
* @param action The action that triggered this saga.
|
||||
*/
|
||||
function* handleFlashFirmware(action: ReturnType<typeof flashFirmware>): Generator {
|
||||
const toaster = yield* getContext<IToaster>('toaster');
|
||||
const toaster = yield* getContext<ToasterInstance>('toaster');
|
||||
|
||||
try {
|
||||
let firmware: Uint8Array | undefined = undefined;
|
||||
@@ -704,7 +704,7 @@ function* handleFlashUsbDfu(action: ReturnType<typeof firmwareFlashUsbDfu>): Gen
|
||||
dfu.dfuseStartAddress = dfuFirmwareStartAddress;
|
||||
const writeProc = dfu.write(1024, firmware, true);
|
||||
|
||||
const toaster = yield* getContext<IToaster>('toaster');
|
||||
const toaster = yield* getContext<ToasterInstance>('toaster');
|
||||
|
||||
defer.push(
|
||||
writeProc.events.on('erase/process', (sent, total) => {
|
||||
|
||||
+4
-4
@@ -1,19 +1,19 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021-2022 The Pybricks Authors
|
||||
|
||||
import { IToastProps, IToaster, Toaster } from '@blueprintjs/core';
|
||||
import { ToastProps, Toaster, ToasterInstance } from '@blueprintjs/core';
|
||||
import { I18nContext, I18nManager } from '@shopify/react-i18n';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
/**
|
||||
* Creates an `IToaster` for static usage similar to `Toaster.create()` except
|
||||
* Creates an `ToasterInstance` for static usage similar to `Toaster.create()` except
|
||||
* that it is wrapped in an `I18nContext.Provider` so that messages can be
|
||||
* translated.
|
||||
*
|
||||
* @param i18n The i18n manager object.
|
||||
*/
|
||||
export function create(i18n: I18nManager): IToaster {
|
||||
export function create(i18n: I18nManager): ToasterInstance {
|
||||
const containerElement = document.createElement('div');
|
||||
|
||||
document.body.appendChild(containerElement);
|
||||
@@ -54,4 +54,4 @@ export type ToastActionHandler<A extends string> = (action: A) => void;
|
||||
export type CreateToast<
|
||||
P extends Record<string, unknown> = never,
|
||||
A extends string = 'dismiss',
|
||||
> = (onAction: ToastActionHandler<A>, props: P) => IToastProps;
|
||||
> = (onAction: ToastActionHandler<A>, props: P) => ToastProps;
|
||||
|
||||
@@ -60,6 +60,29 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// shared styles
|
||||
|
||||
.pb-dropzone-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: bp.$pt-grid-size * 2;
|
||||
border-width: 2;
|
||||
border-radius: 2;
|
||||
border-style: dashed;
|
||||
border-color: bp.$pt-divider-black;
|
||||
background-color: bp.$pt-app-background-color;
|
||||
color: bp.$pt-text-color-muted;
|
||||
outline: none;
|
||||
transition: border 0.24s ease-in-out;
|
||||
|
||||
.#{bp.$ns}-dark & {
|
||||
border-color: bp.$pt-dark-divider-white;
|
||||
background-color: bp.$pt-dark-app-background-color;
|
||||
color: bp.$pt-dark-text-color-muted;
|
||||
}
|
||||
}
|
||||
|
||||
// global style tweaks
|
||||
|
||||
.#{bp.$ns}-toast {
|
||||
|
||||
@@ -68,6 +68,27 @@ if (appVersion.match(/beta/)) {
|
||||
document.body.classList.add('pb-beta');
|
||||
}
|
||||
|
||||
// prevent default drag/drop which just "downloads" any file dropped anywhere
|
||||
// in the browser window
|
||||
|
||||
const dragEventHandler = (e: DragEvent) => {
|
||||
if (
|
||||
e.target instanceof Element &&
|
||||
!e.target.classList.contains('pb-dropzone-root')
|
||||
) {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'none';
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('dragenter', dragEventHandler, false);
|
||||
window.addEventListener('dragover', dragEventHandler);
|
||||
window.addEventListener('drop', dragEventHandler);
|
||||
|
||||
sagaMiddleware.run(rootSaga);
|
||||
|
||||
ReactDOM.render(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2021-2022 The Pybricks Authors
|
||||
|
||||
import { IToaster } from '@blueprintjs/core';
|
||||
import { ToasterInstance } from '@blueprintjs/core';
|
||||
import { FirmwareReaderError, FirmwareReaderErrorCode } from '@pybricks/firmware';
|
||||
import { I18nManager } from '@shopify/react-i18n';
|
||||
import { AnyAction } from 'redux';
|
||||
@@ -37,7 +37,7 @@ import { add } from './actions';
|
||||
import { I18nId } from './i18n';
|
||||
import notification from './sagas';
|
||||
|
||||
function createTestToasterSaga(): { toaster: IToaster; saga: AsyncSaga } {
|
||||
function createTestToasterSaga(): { toaster: ToasterInstance; saga: AsyncSaga } {
|
||||
const i18n = new I18nManager({ locale: 'en' });
|
||||
const toaster = i18nToaster.create(i18n);
|
||||
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
|
||||
// Saga for managing notifications (toasts)
|
||||
|
||||
import { ActionProps, IToaster, IconName, Intent, LinkProps } from '@blueprintjs/core';
|
||||
import {
|
||||
ActionProps,
|
||||
IconName,
|
||||
Intent,
|
||||
LinkProps,
|
||||
ToasterInstance,
|
||||
} from '@blueprintjs/core';
|
||||
import { Replacements } from '@shopify/react-i18n';
|
||||
import React from 'react';
|
||||
import { channel } from 'redux-saga';
|
||||
@@ -34,7 +40,7 @@ import { add as addNotification } from './actions';
|
||||
import { I18nId } from './i18n';
|
||||
|
||||
type NotificationContext = {
|
||||
toaster: IToaster;
|
||||
toaster: ToasterInstance;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -81,7 +87,7 @@ function mapIcon(level: Level): IconName | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a URL to an action that can be passed to `IToaster.show()`.
|
||||
* Converts a URL to an action that can be passed to `ToasterInstance.show()`.
|
||||
* @param helpUrl A URL.
|
||||
*/
|
||||
function helpAction(helpUrl: string): ActionProps & LinkProps {
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020-2022 The Pybricks Authors
|
||||
|
||||
import {
|
||||
Button,
|
||||
IRef,
|
||||
Intent,
|
||||
Spinner,
|
||||
SpinnerSize,
|
||||
useHotkeys,
|
||||
} from '@blueprintjs/core';
|
||||
import { Button, Intent, Spinner, SpinnerSize, useHotkeys } from '@blueprintjs/core';
|
||||
import { Tooltip2 } from '@blueprintjs/popover2';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { tooltipDelay } from '../app/constants';
|
||||
@@ -100,7 +93,7 @@ const ActionButton: React.VoidFunctionComponent<ActionButtonProps> = ({
|
||||
<Button
|
||||
id={id}
|
||||
aria-label={label}
|
||||
elementRef={tooltipTargetRef as IRef<HTMLButtonElement>}
|
||||
elementRef={tooltipTargetRef as React.Ref<HTMLButtonElement>}
|
||||
{...tooltipTargetProps}
|
||||
// https://github.com/palantir/blueprint/pull/5300
|
||||
aria-haspopup={undefined}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright (c) 2020-2022 The Pybricks Authors
|
||||
|
||||
import { Button, IRef, Intent, Spinner, SpinnerSize } from '@blueprintjs/core';
|
||||
import { Button, Intent, Spinner, SpinnerSize } from '@blueprintjs/core';
|
||||
import { Tooltip2 } from '@blueprintjs/popover2';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
@@ -116,7 +116,7 @@ const OpenFileButton: React.VoidFunctionComponent<OpenFileButtonProps> = ({
|
||||
id,
|
||||
'aria-label': label,
|
||||
refKey: 'elementRef',
|
||||
elementRef: tooltipTargetRef as IRef<HTMLButtonElement>,
|
||||
elementRef: tooltipTargetRef as React.Ref<HTMLButtonElement>,
|
||||
...tooltipTargetProps,
|
||||
// https://github.com/palantir/blueprint/pull/5300
|
||||
'aria-haspopup': undefined,
|
||||
|
||||
Reference in New Issue
Block a user