diff --git a/lib/gui/app/components/progress-button/progress-button.tsx b/lib/gui/app/components/progress-button/progress-button.tsx index 97258d67..62fcff92 100644 --- a/lib/gui/app/components/progress-button/progress-button.tsx +++ b/lib/gui/app/components/progress-button/progress-button.tsx @@ -35,6 +35,7 @@ const FlashProgressBar = styled(ProgressBar)` width: 220px; height: 12px; + margin-bottom: 6px; border-radius: 14px; font-size: 16px; line-height: 48px; @@ -81,8 +82,16 @@ export class ProgressButton extends React.PureComponent { }); if (this.props.active) { return ( -
- + <> + {status}  {position} @@ -93,7 +102,7 @@ export class ProgressButton extends React.PureComponent { background={colors[this.props.type]} value={this.props.percentage} /> -
+ ); } return ( diff --git a/lib/gui/app/components/target-selector/target-selector-modal.tsx b/lib/gui/app/components/target-selector/target-selector-modal.tsx index 7573088e..dd533d8e 100644 --- a/lib/gui/app/components/target-selector/target-selector-modal.tsx +++ b/lib/gui/app/components/target-selector/target-selector-modal.tsx @@ -19,17 +19,24 @@ import { faExclamationTriangle, } from '@fortawesome/free-solid-svg-icons'; import { Drive as DrivelistDrive } from 'drivelist'; -import * as _ from 'lodash'; import * as React from 'react'; -import { Badge, Table as BaseTable, Txt, Flex, Link } from 'rendition'; +import { + Badge, + Table, + Txt, + Flex, + Link, + TableColumn, + ModalProps, +} from 'rendition'; import styled from 'styled-components'; import { getDriveImageCompatibilityStatuses, hasListDriveImageCompatibilityStatus, isDriveValid, - hasDriveImageCompatibilityStatus, TargetStatus, + Image, } from '../../../../shared/drive-constraints'; import { compatibility } from '../../../../shared/messages'; import { bytesToClosestUnit } from '../../../../shared/units'; @@ -63,13 +70,32 @@ export interface DrivelistTarget extends DrivelistDrive { * containing the status type (ERROR, WARNING), and accompanying * status message. */ -function getDriveStatuses(drive: DrivelistTarget): TargetStatus[] { - return getDriveImageCompatibilityStatuses(drive, getImage()); +function getDriveStatuses( + drive: DrivelistTarget, + image: Image, +): TargetStatus[] { + return getDriveImageCompatibilityStatuses(drive, image); } +const ScrollableFlex = styled(Flex)` + overflow: auto; + + ::-webkit-scrollbar { + display: none; + } +`; + const TargetsTable = styled(({ refFn, ...props }) => { - return ref={refFn} {...props}>; + return ( +
+ ref={refFn} {...props} /> +
+ ); })` + > div { + overflow: visible; + } + [data-display='table-head'] [data-display='table-row'] > [data-display='table-cell']:first-child { @@ -114,17 +140,6 @@ function badgeShadeFromStatus(status: string) { } } -function renderStatuses(statuses: TargetStatus[]) { - return _.map(statuses, (status) => { - const badgeShade = badgeShadeFromStatus(status.message); - return ( - - {status.message} - - ); - }); -} - const InitProgress = styled( ({ value, @@ -152,8 +167,127 @@ const InitProgress = styled( } `; -function renderProgress(progress: number) { - if (progress) { +interface TableData extends DrivelistTarget { + disabled: boolean; + extra: TargetStatus[] | number; +} + +interface TargetSelectorModalProps extends Omit { + done: (targets: DrivelistTarget[]) => void; +} + +interface TargetSelectorModalState { + drives: any[]; + image: Image; + missingDriversModal: { drive?: DriverlessDrive }; + selectedList: any[]; + 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: DrivelistTarget) => { + return drive.isSystem ? ( + + + {description} + + ) : ( + {description} + ); + }, + }, + { + field: 'size', + label: 'Size', + render: bytesToClosestUnit, + }, + { + field: 'link', + label: 'Location', + render: (link: string, drive: DrivelistTarget) => { + return link ? ( + + {drive.displayName} -{' '} + + this.installMissingDrivers(drive)}> + {drive.linkCTA} + + + + ) : ( + {drive.displayName} + ); + }, + }, + { + field: 'extra', + label: ' ', + render: (extra: TargetStatus[] | number) => { + if (typeof extra === 'number') { + return this.renderProgress(extra); + } + return this.renderStatuses(extra); + }, + }, + ]; + } + + private buildTableData(drives: any[], image: any) { + return drives.map((drive) => { + return { + ...drive, + extra: + drive.progress !== undefined + ? drive.progress + : getDriveStatuses(drive, image), + disabled: !isDriveValid(drive, image) || drive.progress !== undefined, + }; + }); + } + + private getDisplayedTargets(enrichedDrivesData: any[]) { + return enrichedDrivesData.filter((drive) => { + const showIfSystemDrive = this.state.showSystemDrives || !drive.isSystem; + return isDriveSelected(drive.device) || showIfSystemDrive; + }); + } + + private getDisabledTargets(drives: any[], image: any): TableData[] { + return drives + .filter( + (drive) => !isDriveValid(drive, image) || drive.progress !== undefined, + ) + .map((drive) => drive.displayName); + } + + private renderProgress(progress: number) { return ( Initializing device @@ -161,116 +295,24 @@ function renderProgress(progress: number) { ); } -} -interface TableData extends DrivelistTarget { - disabled: boolean; -} + private renderStatuses(statuses: TargetStatus[]) { + return ( + // the column render fn expects a single Element + <> + {statuses.map((status) => { + const badgeShade = badgeShadeFromStatus(status.message); + return ( + + {status.message} + + ); + })} + + ); + } -export const TargetSelectorModal = ({ - close, - cancel, -}: { - close: (targets: DrivelistTarget[]) => void; - cancel: () => void; -}) => { - const defaultMissingDriversModalState: { drive?: DriverlessDrive } = {}; - const [missingDriversModal, setMissingDriversModal] = React.useState( - defaultMissingDriversModalState, - ); - const [drives, setDrives] = React.useState(getDrives()); - const [selectedList, setSelected] = React.useState(getSelectedDrives()); - const [showSystemDrives, setShowSystemDrives] = React.useState(false); - const image = getImage(); - const hasStatus = hasListDriveImageCompatibilityStatus(selectedList, image); - - const enrichedDrivesData = _.map(drives, (drive) => { - return { - ...drive, - extra: drive.progress || getDriveStatuses(drive), - disabled: !isDriveValid(drive, image) || drive.progress, - highlighted: hasDriveImageCompatibilityStatus(drive, image), - }; - }); - const normalDrives = _.reject( - enrichedDrivesData, - (drive) => drive.isSystem && !isDriveSelected(drive.device), - ); - const systemDrives = _.filter(enrichedDrivesData, 'isSystem'); - const disabledRows = _.map( - _.filter(drives, (drive) => { - return !isDriveValid(drive, image) || drive.progress; - }), - 'displayName', - ); - - const columns = [ - { - field: 'description', - label: 'Name', - render: (description: string, drive: DrivelistTarget) => { - return drive.isSystem ? ( - - - {description} - - ) : ( - {description} - ); - }, - }, - { - field: 'size', - label: 'Size', - render: (size: number) => { - return bytesToClosestUnit(size); - }, - }, - { - field: 'link', - label: 'Location', - render: (link: string, drive: DrivelistTarget) => { - return !link ? ( - {drive.displayName} - ) : ( - - {drive.displayName} -{' '} - - installMissingDrivers(drive)}> - {drive.linkCTA} - - - - ); - }, - }, - { - field: 'extra', - label: ' ', - render: (extra: TargetStatus[] | number) => { - if (typeof extra === 'number') { - return renderProgress(extra); - } - return renderStatuses(extra); - }, - }, - ]; - - React.useEffect(() => { - const unsubscribe = store.subscribe(() => { - setDrives(getDrives()); - setSelected(getSelectedDrives()); - }); - return unsubscribe; - }); - - /** - * @summary Prompt the user to install missing usbboot drivers - */ - function installMissingDrivers(drive: { + private installMissingDrivers(drive: { link: string; linkTitle: string; linkMessage: string; @@ -278,131 +320,169 @@ export const TargetSelectorModal = ({ if (drive.link) { analytics.logEvent('Open driver link modal', { url: drive.link, - applicationSessionUuid: store.getState().toJS().applicationSessionUuid, - flashingWorkflowUuid: store.getState().toJS().flashingWorkflowUuid, }); - setMissingDriversModal({ drive }); + this.setState({ missingDriversModal: { drive } }); } } - return ( - - - Select target - - - {drives.length} found - - - } - titleDetails={{getDrives().length} found} - cancel={cancel} - done={() => close(selectedList)} - action="Continue" - style={{ - width: '780px', - height: '420px', - }} - primaryButtonProps={{ - primary: !hasStatus, - warning: hasStatus, - }} - > -
- {!hasAvailableDrives() ? ( -
- Plug a target drive -
- ) : ( - - ) => { - if (!_.isNull(t)) { - t.setRowSelection(selectedList); - } - }} - columns={columns} - data={_.uniq( - showSystemDrives - ? normalDrives.concat(systemDrives) - : normalDrives, - )} - disabledRows={disabledRows} - rowKey="displayName" - onCheck={(rows: TableData[]) => { - setSelected(rows); - }} - onRowClick={(row: TableData) => { - if (!row.disabled) { + 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, + showSystemDrives, + drives, + image, + missingDriversModal, + } = this.state; + + const targetsWithTableData = this.buildTableData(drives, image); + const displayedTargets = this.getDisplayedTargets(targetsWithTableData); + const disabledTargets = this.getDisabledTargets(drives, image); + const numberOfSystemDrives = drives.filter((drive) => drive.isSystem) + .length; + const numberOfDisplayedSystemDrives = displayedTargets.filter( + (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="Continue" + 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: TableData[]) => { + this.setState({ + selectedList: rows, + }); + }} + onRowClick={(row: TableData) => { + if (row.disabled) { + return; + } + const newList = [...selectedList]; const selectedIndex = selectedList.findIndex( (target) => target.device === row.device, ); if (selectedIndex === -1) { - selectedList.push(row); - setSelected(_.map(selectedList)); - return; + newList.push(row); + } else { + // Deselect if selected + newList.splice(selectedIndex, 1); } - // Deselect if selected - setSelected( - _.reject( - selectedList, - (drive) => - selectedList[selectedIndex].device === drive.device, - ), - ); - } - }} - > - {!showSystemDrives && ( - setShowSystemDrives(true)}> - - - - Show {drives.length - normalDrives.length} hidden - - - - )} - - )} -
+ this.setState({ + selectedList: newList, + }); + }} + /> + {!showSystemDrives && numberOfHiddenSystemDrives > 0 && ( + this.setState({ showSystemDrives: true })} + > + + + Show {numberOfHiddenSystemDrives} hidden + + + )} + + )} + - {missingDriversModal.drive !== undefined && ( - setMissingDriversModal({})} - done={() => { - try { - if (missingDriversModal.drive !== undefined) { - openExternal(missingDriversModal.drive.link); + {missingDriversModal.drive !== undefined && ( + this.setState({ missingDriversModal: {} })} + done={() => { + try { + if (missingDriversModal.drive !== undefined) { + openExternal(missingDriversModal.drive.link); + } + } catch (error) { + analytics.logException(error); + } finally { + this.setState({ missingDriversModal: {} }); } - } 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` } - }} - 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/pages/main/DriveSelector.tsx b/lib/gui/app/pages/main/DriveSelector.tsx index 2bf9bc00..198ae5e1 100644 --- a/lib/gui/app/pages/main/DriveSelector.tsx +++ b/lib/gui/app/pages/main/DriveSelector.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import * as _ from 'lodash'; import * as React from 'react'; import styled from 'styled-components'; import { TargetSelector } from '../../components/target-selector/target-selector-button'; @@ -23,6 +22,7 @@ import { TargetSelectorModal, } from '../../components/target-selector/target-selector-modal'; import { + isDriveSelected, getImage, getSelectedDrives, deselectDrive, @@ -53,12 +53,11 @@ const StepBorder = styled.div<{ `; const getDriveListLabel = () => { - return _.join( - _.map(getSelectedDrives(), (drive: any) => { + return getSelectedDrives() + .map((drive: any) => { return `${drive.description} (${drive.displayName})`; - }), - '\n', - ); + }) + .join('\n'); }; const shouldShowDrivesButton = () => { @@ -72,6 +71,33 @@ const getDriveSelectionStateSlice = () => ({ image: getImage(), }); +export const selectAllTargets = (modalTargets: DrivelistTarget[]) => { + const selectedDrivesFromState = getSelectedDrives(); + const deselected = selectedDrivesFromState.filter( + (drive) => + !modalTargets.find((modalTarget) => modalTarget.device === drive.device), + ); + // deselect drives + deselected.forEach((drive) => { + analytics.logEvent('Toggle drive', { + drive, + previouslySelected: true, + }); + deselectDrive(drive.device); + }); + // select drives + modalTargets.forEach((drive) => { + // Don't send events for drives that were already selected + if (!isDriveSelected(drive.device)) { + analytics.logEvent('Toggle drive', { + drive, + previouslySelected: false, + }); + } + selectDrive(drive.device); + }); +}; + interface DriveSelectorProps { webviewShowing: boolean; disabled: boolean; @@ -138,19 +164,8 @@ export const DriveSelector = ({ {showTargetSelectorModal && ( setShowTargetSelectorModal(false)} - close={(selectedTargets: DrivelistTarget[]) => { - const selectedDrives = getSelectedDrives(); - if (_.isEmpty(selectedTargets)) { - _.each(_.map(selectedDrives, 'device'), deselectDrive); - } else { - const deselected = _.reject(selectedDrives, (drive) => - _.find(selectedTargets, (row) => row.device === drive.device), - ); - // select drives - _.each(_.map(selectedTargets, 'device'), selectDrive); - // deselect drives - _.each(_.map(deselected, 'device'), deselectDrive); - } + done={(modalTargets) => { + selectAllTargets(modalTargets); setShowTargetSelectorModal(false); }} > diff --git a/lib/gui/app/pages/main/Flash.tsx b/lib/gui/app/pages/main/Flash.tsx index 0c236491..4eeac6af 100644 --- a/lib/gui/app/pages/main/Flash.tsx +++ b/lib/gui/app/pages/main/Flash.tsx @@ -23,10 +23,7 @@ import * as constraints from '../../../../shared/drive-constraints'; import * as messages from '../../../../shared/messages'; import { ProgressButton } from '../../components/progress-button/progress-button'; import { SourceOptions } from '../../components/source-selector/source-selector'; -import { - TargetSelectorModal, - DrivelistTarget, -} from '../../components/target-selector/target-selector-modal'; +import { TargetSelectorModal } from '../../components/target-selector/target-selector-modal'; import * as availableDrives from '../../models/available-drives'; import * as flashState from '../../models/flash-state'; import * as selection from '../../models/selection-state'; @@ -34,6 +31,7 @@ import * as analytics from '../../modules/analytics'; import { scanner as driveScanner } from '../../modules/drive-scanner'; import * as imageWriter from '../../modules/image-writer'; import * as notification from '../../os/notification'; +import { selectAllTargets } from './DriveSelector'; import FlashSvg from '../../../assets/flash.svg'; @@ -331,22 +329,8 @@ export class FlashStep extends React.PureComponent< {this.state.showDriveSelectorModal && ( this.setState({ showDriveSelectorModal: false })} - close={(targets: DrivelistTarget[]) => { - const selectedDrives = selection.getSelectedDrives(); - if (_.isEmpty(targets)) { - _.each( - _.map(selectedDrives, 'device'), - selection.deselectDrive, - ); - } else { - const deselected = _.reject(selectedDrives, (drive) => - _.find(targets, (row) => row.device === drive.device), - ); - // select drives - _.each(_.map(targets, 'device'), selection.selectDrive); - // deselect drives - _.each(_.map(deselected, 'device'), selection.deselectDrive); - } + done={(modalTargets) => { + selectAllTargets(modalTargets); this.setState({ showDriveSelectorModal: false }); }} > diff --git a/lib/gui/app/styled-components.tsx b/lib/gui/app/styled-components.tsx index be711f99..f2260398 100644 --- a/lib/gui/app/styled-components.tsx +++ b/lib/gui/app/styled-components.tsx @@ -124,6 +124,7 @@ export const Modal = styled((props) => { })` > div { padding: 30px; + height: calc(100% - 80px); > h3 { margin: 0;