/* * 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 { faChevronDown, faExclamationTriangle, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { scanner, sourceDestination } from 'etcher-sdk'; import * as React from 'react'; import { Badge, Table, Txt, Flex, Link, TableColumn, ModalProps, } from 'rendition'; import styled from 'styled-components'; import { getDriveImageCompatibilityStatuses, hasListDriveImageCompatibilityStatus, isDriveValid, TargetStatus, Image, } from '../../../../shared/drive-constraints'; import { compatibility } from '../../../../shared/messages'; import { bytesToClosestUnit } from '../../../../shared/units'; import { getDrives, hasAvailableDrives } from '../../models/available-drives'; import { getImage, getSelectedDrives, isDriveSelected, } from '../../models/selection-state'; import { store } from '../../models/store'; import { logEvent, logException } from '../../modules/analytics'; import { open as openExternal } from '../../os/open-external/services/open-external'; import { Modal } from '../../styled-components'; import TargetSVGIcon from '../../../assets/tgt.svg'; interface UsbbootDrive extends sourceDestination.UsbbootDrive { progress: number; } interface DriverlessDrive { displayName: string; // added in app.ts description: string; link: string; linkTitle: string; linkMessage: string; linkCTA: string; } type Target = scanner.adapters.DrivelistDrive | DriverlessDrive | UsbbootDrive; function isUsbbootDrive(drive: Target): drive is UsbbootDrive { return (drive as UsbbootDrive).progress !== undefined; } function isDriverlessDrive(drive: Target): drive is DriverlessDrive { return (drive as DriverlessDrive).link !== undefined; } function isDrivelistDrive( drive: Target, ): drive is scanner.adapters.DrivelistDrive { return typeof (drive as scanner.adapters.DrivelistDrive).size === 'number'; } const ScrollableFlex = styled(Flex)` overflow: auto; ::-webkit-scrollbar { display: none; } > div > div { /* This is required for the sticky table header in TargetsTable */ overflow-x: visible; } `; const TargetsTable = styled(({ refFn, ...props }) => { return (
ref={refFn} {...props} />
); })` [data-display='table-head'] [data-display='table-cell'] { position: sticky; top: 0; background-color: ${(props) => props.theme.colors.quartenary.light}; } [data-display='table-cell']:first-child { padding-left: 15px; } [data-display='table-cell']:last-child { width: 150px; } && [data-display='table-row'] > [data-display='table-cell'] { padding: 6px 8px; color: #2a506f; } `; function badgeShadeFromStatus(status: string) { switch (status) { case compatibility.containsImage(): return 16; case compatibility.system(): return 5; default: return 14; } } const InitProgress = styled( ({ value, ...props }: { value: number; props?: React.ProgressHTMLAttributes; }) => { return ; }, )` /* Reset the default appearance */ appearance: none; ::-webkit-progress-bar { width: 130px; height: 4px; background-color: #dde1f0; border-radius: 14px; } ::-webkit-progress-value { background-color: #1496e1; border-radius: 14px; } `; interface TargetSelectorModalProps extends Omit { done: (targets: scanner.adapters.DrivelistDrive[]) => void; } interface TargetSelectorModalState { drives: Target[]; image: Image; missingDriversModal: { drive?: DriverlessDrive }; selectedList: scanner.adapters.DrivelistDrive[]; showSystemDrives: boolean; } export class TargetSelectorModal extends React.Component< TargetSelectorModalProps, TargetSelectorModalState > { unsubscribe: () => void; tableColumns: Array>; constructor(props: TargetSelectorModalProps) { super(props); const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {}; const selectedList = getSelectedDrives(); this.state = { drives: getDrives(), image: getImage(), missingDriversModal: defaultMissingDriversModalState, selectedList, showSystemDrives: false, }; this.tableColumns = [ { field: 'description', label: 'Name', render: (description: string, drive: Target) => { return isDrivelistDrive(drive) && drive.isSystem ? ( {description} ) : ( {description} ); }, }, { field: 'description', key: 'size', label: 'Size', render: (_description: string, drive: Target) => { if (isDrivelistDrive(drive) && drive.size !== null) { return bytesToClosestUnit(drive.size); } }, }, { field: 'description', key: 'link', label: 'Location', render: (_description: string, drive: Target) => { return ( {drive.displayName} {isDriverlessDrive(drive) && ( <> {' '} -{' '} this.installMissingDrivers(drive)}> {drive.linkCTA} )} ); }, }, { field: 'description', key: 'extra', // Space as empty string would use the field name as label label: ' ', render: (_description: string, drive: Target) => { if (isUsbbootDrive(drive)) { return this.renderProgress(drive.progress); } else if (isDrivelistDrive(drive)) { return this.renderStatuses( getDriveImageCompatibilityStatuses(drive, this.state.image), ); } }, }, ]; } private driveShouldBeDisabled(drive: Target, image: any) { return ( isUsbbootDrive(drive) || isDriverlessDrive(drive) || !isDriveValid(drive, image) ); } private getDisplayedTargets(targets: Target[]): Target[] { return targets.filter((drive) => { return ( isUsbbootDrive(drive) || isDriverlessDrive(drive) || isDriveSelected(drive.device) || this.state.showSystemDrives || !drive.isSystem ); }); } private getDisabledTargets(drives: Target[], image: any): string[] { return drives .filter((drive) => this.driveShouldBeDisabled(drive, image)) .map((drive) => drive.displayName); } private renderProgress(progress: number) { return ( Initializing device ); } private renderStatuses(statuses: TargetStatus[]) { return ( // the column render fn expects a single Element <> {statuses.map((status) => { const badgeShade = badgeShadeFromStatus(status.message); return ( {status.message} ); })} ); } private installMissingDrivers(drive: DriverlessDrive) { if (drive.link) { logEvent('Open driver link modal', { url: drive.link, }); this.setState({ missingDriversModal: { drive } }); } } componentDidMount() { this.unsubscribe = store.subscribe(() => { const drives = getDrives(); const image = getImage(); this.setState({ drives, image, selectedList: getSelectedDrives(), }); }); } componentWillUnmount() { this.unsubscribe(); } render() { const { cancel, done, ...props } = this.props; const { selectedList, drives, image, missingDriversModal } = this.state; const displayedTargets = this.getDisplayedTargets(drives); const disabledTargets = this.getDisabledTargets(drives, image); const numberOfSystemDrives = drives.filter( (drive) => isDrivelistDrive(drive) && drive.isSystem, ).length; const numberOfDisplayedSystemDrives = displayedTargets.filter( (drive) => isDrivelistDrive(drive) && drive.isSystem, ).length; const numberOfHiddenSystemDrives = numberOfSystemDrives - numberOfDisplayedSystemDrives; const hasStatus = hasListDriveImageCompatibilityStatus(selectedList, image); return ( Select target {drives.length} found } titleDetails={{getDrives().length} found} cancel={cancel} done={() => done(selectedList)} action={`Select (${selectedList.length})`} style={{ width: '780px', height: '420px', }} primaryButtonProps={{ primary: !hasStatus, warning: hasStatus, disabled: !hasAvailableDrives(), }} {...props} > {!hasAvailableDrives() ? ( Plug a target drive ) : ( ) => { if (t !== null) { t.setRowSelection(selectedList); } }} columns={this.tableColumns} data={displayedTargets} disabledRows={disabledTargets} rowKey="displayName" onCheck={(rows: Target[]) => { this.setState({ selectedList: rows.filter(isDrivelistDrive), }); }} onRowClick={(row: Target) => { if ( !isDrivelistDrive(row) || this.driveShouldBeDisabled(row, image) ) { return; } const newList = [...selectedList]; const selectedIndex = selectedList.findIndex( (target) => target.device === row.device, ); if (selectedIndex === -1) { newList.push(row); } else { // Deselect if selected newList.splice(selectedIndex, 1); } this.setState({ selectedList: newList, }); }} /> {numberOfHiddenSystemDrives > 0 && ( this.setState({ showSystemDrives: true })} > Show {numberOfHiddenSystemDrives} hidden )} )} {missingDriversModal.drive !== undefined && ( this.setState({ missingDriversModal: {} })} done={() => { try { if (missingDriversModal.drive !== undefined) { openExternal(missingDriversModal.drive.link); } } catch (error) { logException(error); } finally { this.setState({ missingDriversModal: {} }); } }} action="Yes, continue" cancelButtonProps={{ children: 'Cancel', }} children={ missingDriversModal.drive.linkMessage || `Etcher will open ${missingDriversModal.drive.link} in your browser` } /> )} ); } }