diff --git a/src/firmware/actions.ts b/src/firmware/actions.ts index a339e39c..051a95e8 100644 --- a/src/firmware/actions.ts +++ b/src/firmware/actions.ts @@ -3,6 +3,7 @@ import { FirmwareReaderError } from '@pybricks/firmware'; import { createAction } from '../actions'; +import { Hub } from '../components/hubPicker'; export enum MetadataProblem { Missing = 'metadata.missing', @@ -406,3 +407,25 @@ export const firmwareDidInstallPybricks = createAction(() => ({ export const firmwareDidFailToInstallPybricks = createAction(() => ({ type: 'firmware.action.didFailToInstallPybricks', })); + +/** + * Action that triggers the restore official DFU firmware saga. + */ +export const firmwareRestoreOfficialDfu = createAction((hub: Hub) => ({ + type: 'firmware.action.restoreOfficialDfu', + hub, +})); + +/** + * Action that indicates {@link firmwareRestoreOfficialDfu} succeeded. + */ +export const firmwareDidRestoreOfficialDfu = createAction(() => ({ + type: 'firmware.action.didRestoreOfficialDfu', +})); + +/** + * Action that indicates {@link firmwareRestoreOfficialDfu} failed. + */ +export const firmwareDidFailToRestoreOfficialDfu = createAction(() => ({ + type: 'firmware.action.didFailToRestoreOfficialDfu', +})); diff --git a/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.test.tsx b/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.test.tsx new file mode 100644 index 00000000..1320af01 --- /dev/null +++ b/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.test.tsx @@ -0,0 +1,60 @@ +// 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 { Hub } from '../../components/hubPicker'; +import { firmwareRestoreOfficialDfu } from '../actions'; +import RestoreOfficialDialog from './RestoreOfficialDialog'; +import { firmwareRestoreOfficialDialogHide } from './actions'; + +afterEach(() => { + cleanup(); + jest.resetAllMocks(); + localStorage.clear(); +}); + +describe('closing', () => { + it('should close when close button is clicked', async () => { + const [user, dialog, dispatch] = testRender(, { + firmware: { restoreOfficialDialog: { isOpen: true } }, + }); + + await user.click(dialog.getByRole('button', { name: 'Close' })); + + expect(dispatch).toHaveBeenCalledWith(firmwareRestoreOfficialDialogHide()); + }); + + it('should close when done button is clicked', async () => { + const [user, dialog, dispatch] = testRender(, { + firmware: { restoreOfficialDialog: { isOpen: true } }, + }); + + await user.click(dialog.getByRole('button', { name: 'Next' })); + await user.click(dialog.getByRole('button', { name: 'Done' })); + + expect(dispatch).toHaveBeenCalledWith(firmwareRestoreOfficialDialogHide()); + }); +}); + +describe('flashing', () => { + it.each([ + ['SPIKE Prime Hub', Hub.Prime], + ['SPIKE Essential Hub', Hub.Essential], + ['MINDSTORMS Robot Inventor Hub', Hub.Inventor], + ])( + 'should flash %s when flash button is clicked', + async (hubName: string, hub: Hub) => { + const [user, dialog, dispatch] = testRender(, { + firmware: { restoreOfficialDialog: { isOpen: true } }, + }); + + await user.click(dialog.getByRole('radio', { name: hubName })); + await user.click(dialog.getByRole('button', { name: 'Next' })); + await user.click(dialog.getByRole('button', { name: 'Flash' })); + + expect(dispatch).toHaveBeenCalledWith(firmwareRestoreOfficialDfu(hub)); + }, + ); +}); diff --git a/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.tsx b/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.tsx index d59b5951..26b581f5 100644 --- a/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.tsx +++ b/src/firmware/restoreOfficialDialog/RestoreOfficialDialog.tsx @@ -1,53 +1,129 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2022 The Pybricks Authors -import { Classes, Dialog } from '@blueprintjs/core'; +import { + Button, + Classes, + DialogStep, + Intent, + MultistepDialog, +} from '@blueprintjs/core'; import classNames from 'classnames'; import React from 'react'; import { useDispatch } from 'react-redux'; import { + legoRegisteredTrademark, pybricksBleFirmwareRestoreVideoUrl, - pybricksDfuRestoreUrl, } from '../../app/constants'; +import { hubHasUSB } from '../../components/hubPicker'; +import { HubPicker } from '../../components/hubPicker/HubPicker'; +import { useHubPickerSelectedHub } from '../../components/hubPicker/hooks'; import { useSelector } from '../../reducers'; import ExternalLinkIcon from '../../utils/ExternalLinkIcon'; +import { firmwareRestoreOfficialDfu } from '../actions'; +import BootloaderInstructions from '../bootloaderInstructions/BootloaderInstructions'; import { firmwareRestoreOfficialDialogHide } from './actions'; import { useI18n } from './i18n'; +const SelectHubPanel: React.VoidFunctionComponent = () => { + const i18n = useI18n(); + + return ( +
+

+ {i18n.translate('selectHubPanel.message', { + lego: legoRegisteredTrademark, + next: ( + {i18n.translate('selectHubPanel.nextButton')} + ), + })} +

+
+ +
+ ); +}; + +const RestoreFirmwarePanel: React.VoidFunctionComponent = () => { + const [hubType] = useHubPickerSelectedHub(); + const dispatch = useDispatch(); + const i18n = useI18n(); + + return ( +
+ + {hubHasUSB(hubType) ? ( + <> +

+ {i18n.translate('restoreFirmwarePanel.instruction2.dfu', { + flashFirmware: ( + + {i18n.translate('restoreFirmwarePanel.flashButton')} + + ), + lego: legoRegisteredTrademark, + })} +

+
+ + + ) : ( +

+ {i18n.translate('restoreFirmwarePanel.instruction2.ble.message', { + lego: legoRegisteredTrademark, + thisVideo: ( + <> + + {i18n.translate( + 'restoreFirmwarePanel.instruction2.ble.thisVideo', + )} + + + + ), + })} +

+ )} +
+ ); +}; + const RestoreOfficialDialog: React.VoidFunctionComponent = () => { const { isOpen } = useSelector((s) => s.firmware.restoreOfficialDialog); const dispatch = useDispatch(); const i18n = useI18n(); return ( - dispatch(firmwareRestoreOfficialDialogHide())} + finalButtonProps={{ + text: i18n.translate('doneButton.label'), + onClick: () => dispatch(firmwareRestoreOfficialDialogHide()), + }} > -
-

{i18n.translate('poweredUpHubs.title')}

-

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

-

- - {i18n.translate('poweredUpHubs.action')} - - -

-

{i18n.translate('spikeHubs.title')}

-

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

-

- - {i18n.translate('spikeHubs.action')} - - -

-
-
+ } + nextButtonProps={{ text: i18n.translate('selectHubPanel.nextButton') }} + /> + } + /> + ); }; diff --git a/src/firmware/restoreOfficialDialog/translations/en.json b/src/firmware/restoreOfficialDialog/translations/en.json index cd36ad08..95727a3e 100644 --- a/src/firmware/restoreOfficialDialog/translations/en.json +++ b/src/firmware/restoreOfficialDialog/translations/en.json @@ -1,13 +1,22 @@ { - "title": "Restore official LEGO® Firmware", - "poweredUpHubs": { - "title": "Powered Up Hubs", - "message": "The official firmware can be restored on Powered Up hubs by putting the hub in bootloader mode and connecting to the hub using one of the official LEGO apps.", - "action": "Watch video." + "title": "Restore official {lego} Firmware", + "doneButton": { + "label": "Done" }, - "spikeHubs": { - "title": "SPIKE/MINDSTORMS Hubs", - "message": "The official LEGO software for these hubs does not have a way to restore the firmware on these hubs. So, we have provided a special site to do this for you.", - "action": "Pybricks DFU restore tool." + "selectHubPanel": { + "title": "Select hub", + "message": "Select the hub to restore the official {lego} firmware on, then click {next}.", + "nextButton": "Next" + }, + "restoreFirmwarePanel": { + "title": "Restore firmware", + "instruction2": { + "ble": { + "message": "Then use the official {lego} app for this hub to connect to the hub and restore the firmware. Watch {thisVideo} to see how.", + "thisVideo": "this video" + }, + "dfu": "Then click the {flashFirmware} button below to connect to the hub and flash the firmware. After flashing is complete, connect the hub to one of the official {lego} apps and use it to update to the latest official firmware." + }, + "flashButton": "Flash" } }