etcher/lib/gui/app/components/target-selector/target-selector-modal.tsx
Lorenzo Alberto Maria Ambrosi 14e4cbf749 Add icon to plug targets in targets modal
Change-type: patch
Signed-off-by: Lorenzo Alberto Maria Ambrosi <lorenzothunder.ambrosi@gmail.com>
2020-06-19 17:15:26 +02:00

490 lines
12 KiB
TypeScript

/*
* 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 (
<div>
<Table<Target> ref={refFn} {...props} />
</div>
);
})`
[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<Element>;
}) => {
return <progress max="100" value={value} {...props} />;
},
)`
/* 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<ModalProps, 'done'> {
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<TableColumn<Target>>;
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 ? (
<Flex alignItems="center">
<FontAwesomeIcon
style={{ color: '#fca321' }}
icon={faExclamationTriangle}
/>
<Txt ml={8}>{description}</Txt>
</Flex>
) : (
<Txt>{description}</Txt>
);
},
},
{
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 (
<Txt>
{drive.displayName}
{isDriverlessDrive(drive) && (
<>
{' '}
-{' '}
<b>
<a onClick={() => this.installMissingDrivers(drive)}>
{drive.linkCTA}
</a>
</b>
</>
)}
</Txt>
);
},
},
{
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 (
<Flex flexDirection="column">
<Txt fontSize={12}>Initializing device</Txt>
<InitProgress value={progress} />
</Flex>
);
}
private renderStatuses(statuses: TargetStatus[]) {
return (
// the column render fn expects a single Element
<>
{statuses.map((status) => {
const badgeShade = badgeShadeFromStatus(status.message);
return (
<Badge key={status.message} shade={badgeShade}>
{status.message}
</Badge>
);
})}
</>
);
}
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 (
<Modal
titleElement={
<Flex alignItems="baseline" mb={18}>
<Txt fontSize={24} align="left">
Select target
</Txt>
<Txt
fontSize={11}
ml={12}
color="#5b82a7"
style={{ fontWeight: 600 }}
>
{drives.length} found
</Txt>
</Flex>
}
titleDetails={<Txt fontSize={11}>{getDrives().length} found</Txt>}
cancel={cancel}
done={() => done(selectedList)}
action={`Select (${selectedList.length})`}
style={{
width: '780px',
height: '420px',
}}
primaryButtonProps={{
primary: !hasStatus,
warning: hasStatus,
disabled: !hasAvailableDrives(),
}}
{...props}
>
<Flex width="100%" height="100%">
{!hasAvailableDrives() ? (
<Flex
flexDirection="column"
justifyContent="center"
alignItems="center"
width="100%"
>
<TargetSVGIcon width="40px" height="90px" />
<b>Plug a target drive</b>
</Flex>
) : (
<ScrollableFlex
flexDirection="column"
width="100%"
height="calc(100% - 15px)"
>
<TargetsTable
refFn={(t: Table<Target>) => {
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 && (
<Link
mt={15}
mb={15}
onClick={() => this.setState({ showSystemDrives: true })}
>
<Flex alignItems="center">
<FontAwesomeIcon icon={faChevronDown} />
<Txt ml={8}>Show {numberOfHiddenSystemDrives} hidden</Txt>
</Flex>
</Link>
)}
</ScrollableFlex>
)}
</Flex>
{missingDriversModal.drive !== undefined && (
<Modal
width={400}
title={missingDriversModal.drive.linkTitle}
cancel={() => 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`
}
/>
)}
</Modal>
);
}
}