diff --git a/lib/gui/app/components/drive-selector/DriveSelectorModal.jsx b/lib/gui/app/components/drive-selector/DriveSelectorModal.jsx deleted file mode 100644 index 3083a89d..00000000 --- a/lib/gui/app/components/drive-selector/DriveSelectorModal.jsx +++ /dev/null @@ -1,323 +0,0 @@ -/* - * Copyright 2019 balena.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -'use strict' - -const _ = require('lodash') -const React = require('react') -const { Modal } = require('rendition') -const { - isDriveValid, - getDriveImageCompatibilityStatuses, - hasListDriveImageCompatibilityStatus, - COMPATIBILITY_STATUS_TYPES -} = require('../../../../shared/drive-constraints') -const { store } = require('../../models/store') -const analytics = require('../../modules/analytics') -const availableDrives = require('../../models/available-drives') -const selectionState = require('../../models/selection-state') -const { bytesToClosestUnit } = require('../../../../shared/units') -const { open: openExternal } = require('../../os/open-external/services/open-external') - -/** - * @summary Determine if we can change a drive's selection state - * @function - * @private - * - * @param {Object} drive - drive - * @returns {Promise} - * - * @example - * shouldChangeDriveSelectionState(drive) - * .then((shouldChangeDriveSelectionState) => { - * if (shouldChangeDriveSelectionState) doSomething(); - * }); - */ -const shouldChangeDriveSelectionState = (drive) => { - return isDriveValid(drive, selectionState.getImage()) -} - -/** - * @summary Toggle a drive selection - * @function - * @public - * - * @param {Object} drive - drive - * @returns {void} - * - * @example - * toggleDrive({ - * device: '/dev/disk2', - * size: 999999999, - * name: 'Cruzer USB drive' - * }); - */ -const toggleDrive = (drive) => { - const canChangeDriveSelectionState = shouldChangeDriveSelectionState(drive) - - if (canChangeDriveSelectionState) { - analytics.logEvent('Toggle drive', { - drive, - previouslySelected: selectionState.isDriveSelected(availableDrives.device), - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid - }) - - selectionState.toggleDrive(drive.device) - } -} - -/** - * @summary Get a drive's compatibility status object(s) - * @function - * @public - * - * @description - * Given a drive, return its compatibility status with the selected image, - * containing the status type (ERROR, WARNING), and accompanying - * status message. - * - * @returns {Object[]} list of objects containing statuses - * - * @example - * const statuses = getDriveStatuses(drive); - * - * for ({ type, message } of statuses) { - * // do something - * } - */ -const getDriveStatuses = (drive) => { - return getDriveImageCompatibilityStatuses(drive, selectionState.getImage()) -} - -/** - * @summary Keyboard event drive toggling - * @function - * @public - * - * @description - * Keyboard-event specific entry to the toggleDrive function. - * - * @param {Object} drive - drive - * @param {Object} evt - event - * - * @example - *
- * Tab-select me and press enter or space! - *
- */ -const keyboardToggleDrive = (drive, evt) => { - const ENTER = 13 - const SPACE = 32 - if (_.includes([ ENTER, SPACE ], evt.keyCode)) { - toggleDrive(drive) - } -} - -const DriveSelectorModal = ({ close }) => { - const [ confirmModal, setConfirmModal ] = React.useState({ open: false }) - const [ drives, setDrives ] = React.useState(availableDrives.getDrives()) - - React.useEffect(() => { - const unsubscribe = store.subscribe(() => { - setDrives(availableDrives.getDrives()) - }) - return unsubscribe - }) - - /** - * @summary Prompt the user to install missing usbboot drivers - * @function - * @public - * - * @param {Object} drive - drive - * @returns {void} - * - * @example - * installMissingDrivers({ - * linkTitle: 'Go to example.com', - * linkMessage: 'Examples are great, right?', - * linkCTA: 'Call To Action', - * link: 'https://example.com' - * }); - */ - const installMissingDrivers = (drive) => { - if (drive.link) { - analytics.logEvent('Open driver link modal', { - url: drive.link, - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid - }) - - setConfirmModal({ - open: true, - options: { - width: 400, - title: drive.linkTitle, - cancel: () => setConfirmModal({ open: false }), - done: async (shouldContinue) => { - try { - if (shouldContinue) { - openExternal(drive.link) - } else { - setConfirmModal({ open: false }) - } - } catch (error) { - analytics.logException(error) - } - }, - action: 'Yes, continue', - cancelButtonProps: { - children: 'Cancel' - }, - children: drive.linkMessage || `Etcher will open ${drive.link} in your browser` - } - }) - } - } - - /** - * @summary Select a drive and close the modal - * @function - * @public - * - * @param {Object} drive - drive - * @returns {void} - * - * @example - * selectDriveAndClose({ - * device: '/dev/disk2', - * size: 999999999, - * name: 'Cruzer USB drive' - * }); - */ - const selectDriveAndClose = async (drive) => { - const canChangeDriveSelectionState = await shouldChangeDriveSelectionState(drive) - - if (canChangeDriveSelectionState) { - selectionState.selectDrive(drive.device) - - analytics.logEvent('Drive selected (double click)', { - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid - }) - - close() - } - } - - const hasStatus = hasListDriveImageCompatibilityStatus(selectionState.getSelectedDrives(), selectionState.getImage()) - - return ( - -
- -
- - {confirmModal.open && - - } -
- ) -} - -module.exports = DriveSelectorModal diff --git a/lib/gui/app/components/drive-selector/DriveSelectorModal.tsx b/lib/gui/app/components/drive-selector/DriveSelectorModal.tsx new file mode 100644 index 00000000..97aaad52 --- /dev/null +++ b/lib/gui/app/components/drive-selector/DriveSelectorModal.tsx @@ -0,0 +1,292 @@ +/* + * Copyright 2019 balena.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Drive as DrivelistDrive } from 'drivelist'; +import * as _ from 'lodash'; +import * as React from 'react'; +import { Modal } from 'rendition'; + +import { + COMPATIBILITY_STATUS_TYPES, + getDriveImageCompatibilityStatuses, + hasListDriveImageCompatibilityStatus, + isDriveValid, +} from '../../../../shared/drive-constraints'; +import { bytesToClosestUnit } from '../../../../shared/units'; +import { getDrives, hasAvailableDrives } from '../../models/available-drives'; +import * as selectionState from '../../models/selection-state'; +import { store } from '../../models/store'; +import * as analytics from '../../modules/analytics'; +import { open as openExternal } from '../../os/open-external/services/open-external'; + +/** + * @summary Determine if we can change a drive's selection state + */ +function shouldChangeDriveSelectionState(drive: DrivelistDrive) { + return isDriveValid(drive, selectionState.getImage()); +} + +/** + * @summary Toggle a drive selection + */ +function toggleDrive(drive: DrivelistDrive) { + const canChangeDriveSelectionState = shouldChangeDriveSelectionState(drive); + + if (canChangeDriveSelectionState) { + analytics.logEvent('Toggle drive', { + drive, + previouslySelected: selectionState.isDriveSelected(drive.device), + applicationSessionUuid: store.getState().toJS().applicationSessionUuid, + flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, + }); + + selectionState.toggleDrive(drive.device); + } +} + +/** + * @summary Get a drive's compatibility status object(s) + * + * @description + * Given a drive, return its compatibility status with the selected image, + * containing the status type (ERROR, WARNING), and accompanying + * status message. + */ +function getDriveStatuses( + drive: DrivelistDrive, +): Array<{ type: number; message: string }> { + return getDriveImageCompatibilityStatuses(drive, selectionState.getImage()); +} + +function keyboardToggleDrive( + drive: DrivelistDrive, + event: React.KeyboardEvent, +) { + const ENTER = 13; + const SPACE = 32; + if (_.includes([ENTER, SPACE], event.keyCode)) { + toggleDrive(drive); + } +} + +interface DriverlessDrive { + link: string; + linkTitle: string; + linkMessage: string; +} + +export function DriveSelectorModal({ close }: { close: () => void }) { + const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {}; + const [missingDriversModal, setMissingDriversModal] = React.useState( + defaultMissingDriversModalState, + ); + const [drives, setDrives] = React.useState(getDrives()); + + React.useEffect(() => { + const unsubscribe = store.subscribe(() => { + setDrives(getDrives()); + }); + return unsubscribe; + }); + + /** + * @summary Prompt the user to install missing usbboot drivers + */ + function installMissingDrivers(drive: { + link: string; + linkTitle: string; + linkMessage: string; + }) { + if (drive.link) { + analytics.logEvent('Open driver link modal', { + url: drive.link, + applicationSessionUuid: store.getState().toJS().applicationSessionUuid, + flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, + }); + setMissingDriversModal({ drive }); + } + } + + /** + * @summary Select a drive and close the modal + */ + async function selectDriveAndClose(drive: DrivelistDrive) { + const canChangeDriveSelectionState = await shouldChangeDriveSelectionState( + drive, + ); + + if (canChangeDriveSelectionState) { + selectionState.selectDrive(drive.device); + + analytics.logEvent('Drive selected (double click)', { + applicationSessionUuid: store.getState().toJS().applicationSessionUuid, + flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, + }); + + close(); + } + } + + const hasStatus = hasListDriveImageCompatibilityStatus( + selectionState.getSelectedDrives(), + selectionState.getImage(), + ); + + return ( + +
+
    + {_.map(drives, (drive, index) => { + return ( +
  • attribute but used by css rule) + disabled={!isDriveValid(drive, selectionState.getImage())} + onDoubleClick={() => selectDriveAndClose(drive)} + onClick={() => toggleDrive(drive)} + > + {drive.icon && ( + Drive device type logo + )} +
    keyboardToggleDrive(drive, evt)} + > +
    + {drive.description} + {drive.size && ( + + {' '} + - {bytesToClosestUnit(drive.size)} + + )} +
    + {!drive.link && ( +

    {drive.displayName}

    + )} + {drive.link && ( +

    + {drive.displayName} -{' '} + + installMissingDrivers(drive)}> + {drive.linkCTA} + + +

    + )} + +
    + {_.map(getDriveStatuses(drive), (status, idx) => { + const className = { + [COMPATIBILITY_STATUS_TYPES.WARNING]: 'label-warning', + [COMPATIBILITY_STATUS_TYPES.ERROR]: 'label-danger', + }; + return ( + + {status.message} + + ); + })} +
    + {Boolean(drive.progress) && ( + + )} +
    + + {isDriveValid(drive, selectionState.getImage()) && ( + attribute but used by css rule) + disabled={!selectionState.isDriveSelected(drive.device)} + > + )} +
  • + ); + })} + {!hasAvailableDrives() && ( +
  • +
    + Connect a drive! +
    No removable drive detected.
    +
    +
  • + )} +
+
+ + {missingDriversModal.drive !== undefined && ( + setMissingDriversModal({})} + done={() => { + try { + if (missingDriversModal.drive !== undefined) { + openExternal(missingDriversModal.drive.link); + } + } catch (error) { + analytics.logException(error); + } finally { + setMissingDriversModal({}); + } + }} + action={'Yes, continue'} + cancelButtonProps={{ + children: 'Cancel', + }} + children={ + missingDriversModal.drive.linkMessage || + `Etcher will open ${missingDriversModal.drive.link} in your browser` + } + > + )} +
+ ); +} diff --git a/lib/gui/app/models/available-drives.ts b/lib/gui/app/models/available-drives.ts index c0822898..f9b77df9 100644 --- a/lib/gui/app/models/available-drives.ts +++ b/lib/gui/app/models/available-drives.ts @@ -29,6 +29,6 @@ export function setDrives(drives: any[]) { }); } -export function getDrives() { +export function getDrives(): any[] { return store.getState().toJS().availableDrives; } diff --git a/lib/gui/app/pages/main/DriveSelector.tsx b/lib/gui/app/pages/main/DriveSelector.tsx index 638a674b..6fdf34e0 100644 --- a/lib/gui/app/pages/main/DriveSelector.tsx +++ b/lib/gui/app/pages/main/DriveSelector.tsx @@ -17,7 +17,7 @@ import * as _ from 'lodash'; import * as React from 'react'; import styled from 'styled-components'; -import * as DriveSelectorModal from '../../components/drive-selector/DriveSelectorModal.jsx'; +import { DriveSelectorModal } from '../../components/drive-selector/DriveSelectorModal'; import { TargetSelector } from '../../components/drive-selector/target-selector'; import { SVGIcon } from '../../components/svg-icon/svg-icon'; import { getImage, getSelectedDrives } from '../../models/selection-state'; diff --git a/lib/gui/app/pages/main/Flash.tsx b/lib/gui/app/pages/main/Flash.tsx index 426ca527..4ef5c12a 100644 --- a/lib/gui/app/pages/main/Flash.tsx +++ b/lib/gui/app/pages/main/Flash.tsx @@ -20,7 +20,7 @@ import * as React from 'react'; import { Modal, Txt } from 'rendition'; import * as constraints from '../../../../shared/drive-constraints'; import * as messages from '../../../../shared/messages'; -import * as DriveSelectorModal from '../../components/drive-selector/DriveSelectorModal.jsx'; +import { DriveSelectorModal } from '../../components/drive-selector/DriveSelectorModal'; import { ProgressButton } from '../../components/progress-button/progress-button'; import { SVGIcon } from '../../components/svg-icon/svg-icon'; import * as availableDrives from '../../models/available-drives';