From b4e1c8293dfe8e18ed2fa17d5fff3223c3396bb7 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Thu, 1 Sep 2022 11:45:59 -0500 Subject: [PATCH 1/5] replace use of deprecated IRef --- .../installPybricksDialog/InstallPybricksDialog.tsx | 3 +-- src/toolbar/ActionButton.tsx | 11 ++--------- src/toolbar/OpenFileButton.tsx | 4 ++-- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx index c869cc1f..0827090e 100644 --- a/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx +++ b/src/firmware/installPybricksDialog/InstallPybricksDialog.tsx @@ -11,7 +11,6 @@ import { ControlGroup, DialogStep, FormGroup, - IRef, Icon, InputGroup, Intent, @@ -129,7 +128,7 @@ const SelectHubPanel: React.VoidFunctionComponent = () => { } renderTarget={({ isOpen: _isOpen, ref, ...targetProps }) => ( - )} - /> + + ) : ( + <> +

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

+ + } + renderTarget={({ isOpen: _isOpen, ref, ...targetProps }) => ( + + )} + /> + + )} +
+ + +
+ + {i18n.translate( + 'selectHubPanel.advanced.customFirmwareDropzone.label', + )} +
+
+
); }; @@ -144,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 } @@ -176,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} />
); @@ -441,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, ), @@ -465,7 +655,12 @@ export const InstallPybricksDialog: React.VoidFunctionComponent = () => { } + panel={ + + } nextButtonProps={{ text: i18n.translate('nextButton.label') }} /> { title={i18n.translate('licensePanel.title')} panel={ } @@ -489,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 74955376..64df3c85 100644 --- a/src/firmware/installPybricksDialog/hooks.ts +++ b/src/firmware/installPybricksDialog/hooks.ts @@ -2,26 +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 }; @@ -52,8 +56,8 @@ export function useFirmware(hubType: Hub): State { const isMounted = useIsMounted(); const initialState: State = { - error: undefined, - data: undefined, + firmwareError: undefined, + firmwareData: undefined, }; // Keep state logic separated @@ -62,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; } @@ -96,7 +100,8 @@ 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; @@ -114,7 +119,7 @@ export function useFirmware(hubType: Hub): State { return; } - dispatch({ type: 'error', payload: error as Error }); + dispatch({ type: 'error', payload: ensureError(error) }); } }; @@ -123,3 +128,90 @@ export function useFirmware(hubType: Hub): State { 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/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(