diff --git a/CHANGELOG.md b/CHANGELOG.md index f7acf664..f4aa855c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/alerts.ts b/src/alerts.ts index a203bbff..32f31825 100644 --- a/src/alerts.ts +++ b/src/alerts.ts @@ -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> specific: S, onAlert: AlertCallback, props: AlertProps, -): IToastProps { +): ToastProps { const create = alertDomains[domain][specific] as unknown as CreateToast< Record | never, string diff --git a/src/alerts/sagas.test.ts b/src/alerts/sagas.test.ts index 07c582e1..1553c7ad 100644 --- a/src/alerts/sagas.test.ts +++ b/src/alerts/sagas.test.ts @@ -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(); 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!'); } diff --git a/src/alerts/sagas.ts b/src/alerts/sagas.ts index 421b8c11..66a0ea69 100644 --- a/src/alerts/sagas.ts +++ b/src/alerts/sagas.ts @@ -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): Generator { - const toaster = yield* getContext('toaster'); + const toaster = yield* getContext('toaster'); const key = `${action.domain}.${action.specific}.${JSON.stringify(action.props)}`; diff --git a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx index c869cc1f..e2f24aac 100644 --- a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx +++ b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx @@ -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 ( -
-

{i18n.translate('selectHubPanel.message')}

- - -

- {i18n.translate( - 'selectHubPanel.notOnListButton.info.mindstorms.title', - )} -

-
    -
  • - {i18n.translate( - 'selectHubPanel.notOnListButton.info.mindstorms.rcx', - )} -
  • -
  • - {i18n.translate( - 'selectHubPanel.notOnListButton.info.mindstorms.nxt', - )} -
  • -
  • - {i18n.translate( - 'selectHubPanel.notOnListButton.info.mindstorms.ev3', - )} -
  • -
-

- {i18n.translate( - 'selectHubPanel.notOnListButton.info.poweredUp.title', - )} -

-
    -
  • - {i18n.translate( - 'selectHubPanel.notOnListButton.info.poweredUp.wedo2', - )} - * -
  • -
  • - {i18n.translate( - 'selectHubPanel.notOnListButton.info.poweredUp.duploTrain', - )} - * -
  • -
  • - {i18n.translate( - 'selectHubPanel.notOnListButton.info.poweredUp.mario', - )} -
  • -
+
+

+ {i18n.translate('selectHubPanel.notOnListButton.info.mindstorms.title')} +

+
    +
  • + {i18n.translate( + 'selectHubPanel.notOnListButton.info.mindstorms.rcx', + )} +
  • +
  • + {i18n.translate( + 'selectHubPanel.notOnListButton.info.mindstorms.nxt', + )} +
  • +
  • + {i18n.translate( + 'selectHubPanel.notOnListButton.info.mindstorms.ev3', + )} +
  • +
+

+ {i18n.translate('selectHubPanel.notOnListButton.info.poweredUp.title')} +

+
    +
  • + {i18n.translate( + 'selectHubPanel.notOnListButton.info.poweredUp.wedo2', + )} + * +
  • +
  • + {i18n.translate( + 'selectHubPanel.notOnListButton.info.poweredUp.duploTrain', + )} + * +
  • +
  • + {i18n.translate( + 'selectHubPanel.notOnListButton.info.poweredUp.mario', + )} +
  • +
- - *{' '} - {i18n.translate( - 'selectHubPanel.notOnListButton.info.poweredUp.footnote', - )} - -
- } - renderTarget={({ isOpen: _isOpen, ref, ...targetProps }) => ( - + + *{' '} + {i18n.translate( + 'selectHubPanel.notOnListButton.info.poweredUp.footnote', )} - /> + +
+ ); +}; + +type SelectHubPanelProps = { + customFirmwareZip: File | undefined; + onCustomFirmwareZip: (firmwareZip: File | undefined) => void; +}; + +const SelectHubPanel: React.VoidFunctionComponent = ({ + 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 ( +
+ {isCustomFirmwareRequested ? ( + <> +

{i18n.translate('selectHubPanel.customFirmware.message')}

+

+ {i18n.translate('selectHubPanel.customFirmware.hubType', { + hubTypeName: getHubTypeNameFromMetadata( + customFirmwareData?.metadata, + ), + })} +

+

+ {i18n.translate( + 'selectHubPanel.customFirmware.firmwareVersion', + { + version: + customFirmwareData?.metadata['firmware-version'], + }, + )} +

+ + + ) : ( + <> +

{i18n.translate('selectHubPanel.message')}

+ + } + renderTarget={({ isOpen: _isOpen, ref, ...targetProps }) => ( + + )} + /> + + )} +
+ + +
+ + {i18n.translate( + 'selectHubPanel.advanced.customFirmwareDropzone.label', + )} +
+
+
); }; @@ -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 = ({ 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 (
- {data ? ( -
{data.licenseText}
+ {selectedFirmwareData ? ( +
{selectedFirmwareData.licenseText}
) : ( } + icon={selectedFirmwareError ? 'error' : } description={ - error + selectedFirmwareError ? i18n.translate('licensePanel.licenseText.error') : undefined } @@ -175,7 +356,7 @@ const AcceptLicensePanel: React.VoidFunctionComponent = label={i18n.translate('licensePanel.acceptCheckbox.label')} checked={licenseAccepted} onChange={(e) => onLicenseAcceptedChanged(e.currentTarget.checked)} - disabled={!data} + disabled={!selectedFirmwareData} />
); @@ -440,9 +621,19 @@ export const InstallPybricksDialog: React.VoidFunctionComponent = () => { const [selectedIncludeFile, setSelectedIncludeFile] = useState(); const [licenseAccepted, setLicenseAccepted] = useState(false); const [hubType] = useHubPickerSelectedHub(); - const { data } = useFirmware(hubType); + const { firmwareData } = useFirmware(hubType); + const [customFirmwareZip, setCustomFirmwareZip] = useState(); + const { isCustomFirmwareRequested, customFirmwareData } = + useCustomFirmware(customFirmwareZip); const i18n = useI18n(); + const selectedFirmwareData = isCustomFirmwareRequested + ? customFirmwareData + : firmwareData; + const selectedHubType = isCustomFirmwareRequested + ? getHubTypeFromMetadata(customFirmwareData?.metadata, hubType) + : hubType; + return ( { 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 = () => { } + panel={ + + } nextButtonProps={{ text: i18n.translate('nextButton.label') }} /> { title={i18n.translate('licensePanel.title')} panel={ } @@ -488,7 +685,7 @@ export const InstallPybricksDialog: React.VoidFunctionComponent = () => { title={i18n.translate('optionsPanel.title')} panel={ { } + panel={} backButtonProps={{ text: i18n.translate('backButton.label') }} /> diff --git a/src/firmware/installPybricksDialog/hooks.ts b/src/firmware/installPybricksDialog/hooks.ts index 42535940..64df3c85 100644 --- a/src/firmware/installPybricksDialog/hooks.ts +++ b/src/firmware/installPybricksDialog/hooks.ts @@ -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([ export function useFirmware(hubType: Hub): State { const url = firmwareZipMap.get(hubType); const cache = useRef({}); - - // Used to prevent state update if the component is unmounted - const cancelRequest = useRef(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, + }; +} diff --git a/src/firmware/installPybricksDialog/installPybricksDialog.scss b/src/firmware/installPybricksDialog/installPybricksDialog.scss index 670d8deb..0734e061 100644 --- a/src/firmware/installPybricksDialog/installPybricksDialog.scss +++ b/src/firmware/installPybricksDialog/installPybricksDialog.scss @@ -12,6 +12,13 @@ gap: bp.$pt-grid-size; } + &-selectHub { + &-advanced { + margin-top: auto; + width: 100%; + } + } + &-license { &-text { flex-grow: 1; diff --git a/src/firmware/installPybricksDialog/translations/en.json b/src/firmware/installPybricksDialog/translations/en.json index c3159382..12eaeed0 100644 --- a/src/firmware/installPybricksDialog/translations/en.json +++ b/src/firmware/installPybricksDialog/translations/en.json @@ -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": { diff --git a/src/firmware/sagas.test.ts b/src/firmware/sagas.test.ts index e9ae1c9c..dcc8e1cd 100644 --- a/src/firmware/sagas.test.ts +++ b/src/firmware/sagas.test.ts @@ -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(), + toaster: mock(), }); // saga is triggered by this action @@ -231,7 +231,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -281,7 +281,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -349,7 +349,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -413,7 +413,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -480,7 +480,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -540,7 +540,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -606,7 +606,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -689,7 +689,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -755,7 +755,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -854,7 +854,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -962,7 +962,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -1110,7 +1110,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -1256,7 +1256,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -1409,7 +1409,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -1457,7 +1457,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -1504,7 +1504,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -1565,7 +1565,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -1627,7 +1627,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -1692,7 +1692,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action @@ -1782,7 +1782,7 @@ describe('flashFirmware', () => { const saga = new AsyncSaga(flashFirmware, { nextMessageId: createCountFunc(), - toaster: mock(), + toaster: mock(), }); // saga is triggered by this action diff --git a/src/firmware/sagas.ts b/src/firmware/sagas.ts index f0e6f7e9..9c9bdb80 100644 --- a/src/firmware/sagas.ts +++ b/src/firmware/sagas.ts @@ -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([ * parent task). */ function* disconnectAndCancel(): SagaGenerator { - const toaster = yield* getContext('toaster'); + const toaster = yield* getContext('toaster'); toaster.dismiss('firmware.ble.progress'); @@ -333,7 +333,7 @@ function* loadFirmware( * @param action The action that triggered this saga. */ function* handleFlashFirmware(action: ReturnType): Generator { - const toaster = yield* getContext('toaster'); + const toaster = yield* getContext('toaster'); try { let firmware: Uint8Array | undefined = undefined; @@ -704,7 +704,7 @@ function* handleFlashUsbDfu(action: ReturnType): Gen dfu.dfuseStartAddress = dfuFirmwareStartAddress; const writeProc = dfu.write(1024, firmware, true); - const toaster = yield* getContext('toaster'); + const toaster = yield* getContext('toaster'); defer.push( writeProc.events.on('erase/process', (sent, total) => { diff --git a/src/i18nToaster.tsx b/src/i18nToaster.tsx index 72829afc..98415b65 100644 --- a/src/i18nToaster.tsx +++ b/src/i18nToaster.tsx @@ -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 = (action: A) => void; export type CreateToast< P extends Record = never, A extends string = 'dismiss', -> = (onAction: ToastActionHandler, props: P) => IToastProps; +> = (onAction: ToastActionHandler, props: P) => ToastProps; diff --git a/src/index.scss b/src/index.scss index 79a9e6bd..ae1eeb1e 100644 --- a/src/index.scss +++ b/src/index.scss @@ -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 { diff --git a/src/index.tsx b/src/index.tsx index 33d2d5d2..bf0ed2ff 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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( diff --git a/src/notifications/sagas.test.ts b/src/notifications/sagas.test.ts index 4ad88de6..b76599ea 100644 --- a/src/notifications/sagas.test.ts +++ b/src/notifications/sagas.test.ts @@ -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); diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts index 89a84665..43ef72bb 100644 --- a/src/notifications/sagas.ts +++ b/src/notifications/sagas.ts @@ -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 { diff --git a/src/toolbar/ActionButton.tsx b/src/toolbar/ActionButton.tsx index 2ce0dba9..8c76ffa5 100644 --- a/src/toolbar/ActionButton.tsx +++ b/src/toolbar/ActionButton.tsx @@ -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 = ({