]> git.arvados.org - arvados.git/blob - services/workbench2/src/components/file-upload/file-upload.tsx
19378: fixed overflow
[arvados.git] / services / workbench2 / src / components / file-upload / file-upload.tsx
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import React from 'react';
6 import classnames from 'classnames';
7 import { CustomStyleRulesCallback } from 'common/custom-theme';
8 import {
9     Grid,
10     Table,
11     TableBody,
12     TableCell,
13     TableHead,
14     TableRow,
15     Typography,
16     IconButton,
17 } from '@mui/material';
18 import { WithStyles } from '@mui/styles';
19 import withStyles from '@mui/styles/withStyles';
20 import { CloudUploadIcon, RemoveIcon } from "../icon/icon";
21 import { formatFileSize, formatProgress, formatUploadSpeed } from "common/formatters";
22 import { UploadFile } from 'store/file-uploader/file-uploader-actions';
23 import { UploadInput, FileUploadType } from 'components/file-upload/upload-input';
24
25 type CssRules = "dropzoneWrapper" | "container" | "inputContainer" | "uploadIcon"
26     | "dropzoneBorder" | "dropzoneBorderLeft" | "dropzoneBorderRight" | "dropzoneBorderTop" | "dropzoneBorderBottom"
27     | "dropzoneBorderHorzActive" | "dropzoneBorderVertActive" | "deleteButton" | "deleteButtonDisabled" | "deleteIcon";
28
29 const styles: CustomStyleRulesCallback<CssRules> = theme => ({
30     dropzoneWrapper: {
31         width: "100%",
32         height: "200px",
33         position: "relative",
34         border: "1px solid rgba(0, 0, 0, 0.42)",
35         boxSizing: 'border-box',
36         overflowY: "scroll",
37     },
38     dropzoneBorder: {
39         content: "",
40         position: "absolute",
41         transition: "transform 200ms cubic-bezier(0.0, 0, 0.2, 1) 0ms",
42         pointerEvents: "none",
43         backgroundColor: "#6a1b9a"
44     },
45     dropzoneBorderLeft: {
46         left: -1,
47         top: -1,
48         bottom: -1,
49         width: 2,
50         transform: "scaleY(0)",
51     },
52     dropzoneBorderRight: {
53         right: -1,
54         top: -1,
55         bottom: -1,
56         width: 2,
57         transform: "scaleY(0)",
58     },
59     dropzoneBorderTop: {
60         left: 0,
61         right: 0,
62         top: -1,
63         height: 2,
64         transform: "scaleX(0)",
65     },
66     dropzoneBorderBottom: {
67         left: 0,
68         right: 0,
69         bottom: -1,
70         height: 2,
71         transform: "scaleX(0)",
72     },
73     dropzoneBorderHorzActive: {
74         transform: "scaleY(1)"
75     },
76     dropzoneBorderVertActive: {
77         transform: "scaleX(1)"
78     },
79     container: {
80         height: "100%",
81         padding: '16px',
82         display: 'flex',
83         flexDirection: 'column',
84         alignItems: 'center',
85     },
86     inputContainer: {
87         width: '80%',
88         marginTop: '1rem',
89         display: 'flex',
90         alignItems: 'center',
91         justifyContent: 'space-around',
92     },
93     uploadIcon: {
94         verticalAlign: "middle"
95     },
96     deleteButton: {
97         cursor: "pointer"
98     },
99     deleteButtonDisabled: {
100         cursor: "not-allowed"
101     },
102     deleteIcon: {
103         marginLeft: "-6px"
104     }
105 });
106
107 interface FileUploadPropsData {
108     files: UploadFile[];
109     disabled: boolean;
110     onDrop: (files: File[]) => void;
111     onDelete: (file: UploadFile) => void;
112 }
113
114 interface FileUploadState {
115     focused: boolean;
116 }
117
118 export type FileUploadProps = FileUploadPropsData & WithStyles<CssRules>;
119
120 export const FileUpload = withStyles(styles)(
121     class extends React.Component<FileUploadProps, FileUploadState> {
122         constructor(props: FileUploadProps) {
123             super(props);
124             this.state = {
125                 focused: false
126             };
127         }
128         onDelete = (event: React.MouseEvent<HTMLButtonElement>, file: UploadFile): void => {
129             const { onDelete, disabled } = this.props;
130
131             event.stopPropagation();
132
133             if (!disabled) {
134                 onDelete(file);
135             }
136
137             let interval = setInterval(() => {
138                 const key = Object.keys((window as any).cancelTokens).find(key => key.indexOf(file.file.name) > -1);
139
140                 if (key) {
141                     clearInterval(interval);
142                     (window as any).cancelTokens[key]();
143                     delete (window as any).cancelTokens[key];
144                 }
145             }, 100);
146
147         }
148
149         fileInputRef = React.createRef<HTMLInputElement>();
150         folderInputRef = React.createRef<HTMLInputElement>();
151
152         handleDrop = async (event) => {
153             event.preventDefault();
154
155             const items = event.dataTransfer.items;
156             const entries: any[] = [];
157
158             for (let i = 0; i < items.length; i++) {
159                 const entry = items[i].webkitGetAsEntry?.();
160                 if (entry) entries.push(entry);
161             }
162
163             const filesArrays = await Promise.all(entries.map((entry) => traverseFileTree(entry)));
164             const allFiles = filesArrays.flat();
165
166             this.props.onDrop(allFiles as any); // includes `file.relativePath` if needed
167         }
168
169         handleInputChange = (event) => {
170                 const files = Array.from(event.target.files);
171                 this.props.onDrop(files as any);
172             };
173
174         getInputProps = () => ({
175             disabled: this.props.disabled,
176             handleInputChange: this.handleInputChange,
177             onFocus: ()=>this.setState({ focused: true }),
178             onBlur: ()=>this.setState({ focused: false }),
179         })
180
181         render() {
182             const { classes, disabled, files } = this.props;
183             return (
184                 <div className={"file-upload-dropzone " + classes.dropzoneWrapper} >
185                     <div className={classnames(classes.dropzoneBorder, classes.dropzoneBorderLeft, { [classes.dropzoneBorderHorzActive]: this.state.focused })} />
186                     <div className={classnames(classes.dropzoneBorder, classes.dropzoneBorderRight, { [classes.dropzoneBorderHorzActive]: this.state.focused })} />
187                     <div className={classnames(classes.dropzoneBorder, classes.dropzoneBorderTop, { [classes.dropzoneBorderVertActive]: this.state.focused })} />
188                     <div className={classnames(classes.dropzoneBorder, classes.dropzoneBorderBottom, { [classes.dropzoneBorderVertActive]: this.state.focused })} />
189                     <div
190                         onDrop={this.handleDrop}
191                         onDragOver={(e) => e.preventDefault()}
192                         onClick={() => {
193                             const el = document.getElementsByClassName("file-upload-dropzone")[0];
194                             const inputs = el.getElementsByTagName("input");
195                             if (inputs.length > 0) {
196                                 inputs[0].focus();
197                             }
198                         }}
199                         data-cy="drag-and-drop"
200                         >
201                         {files.length === 0 &&
202                             <Grid container justifyContent="center" alignItems="center" className={classes.container}>
203                                 <Grid item component={"span"}>
204                                     <Typography variant='subtitle1'>
205                                         <CloudUploadIcon className={classes.uploadIcon} /> Drag and drop data or click to browse
206                                     </Typography>
207                                 </Grid>
208                                 <Grid item component={"div"} className={classes.inputContainer}>
209                                     <UploadInput type={FileUploadType.FOLDER} inputRef={this.folderInputRef} {...this.getInputProps()} />
210                                     <UploadInput type={FileUploadType.FILE} inputRef={this.fileInputRef} {...this.getInputProps()} />
211                                 </Grid>
212                             </Grid>}
213                         {files.length > 0 &&
214                             <Table style={{ width: "100%" }}>
215                                 <TableHead>
216                                     <TableRow>
217                                         <TableCell>File name</TableCell>
218                                         <TableCell>File size</TableCell>
219                                         <TableCell>Upload speed</TableCell>
220                                         <TableCell>Upload progress</TableCell>
221                                         <TableCell>Delete</TableCell>
222                                     </TableRow>
223                                 </TableHead>
224                                 <TableBody>
225                                     {files.map(f =>
226                                         <TableRow key={f.id}>
227                                             <TableCell>{f.file.name}</TableCell>
228                                             <TableCell>{formatFileSize(f.file.size)}</TableCell>
229                                             <TableCell>{formatUploadSpeed(f.prevLoaded, f.loaded, f.prevTime, f.currentTime)}</TableCell>
230                                             <TableCell>{formatProgress(f.loaded, f.total)}</TableCell>
231                                             <TableCell>
232                                                 <IconButton
233                                                     aria-label="Remove"
234                                                     onClick={(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => this.onDelete(event, f)}
235                                                     className={disabled ? classnames(classes.deleteButtonDisabled, classes.deleteIcon) : classnames(classes.deleteButton, classes.deleteIcon)}
236                                                     size="large">
237                                                     <RemoveIcon />
238                                                 </IconButton>
239                                             </TableCell>
240                                         </TableRow>
241                                     )}
242                                 </TableBody>
243                             </Table>
244                         }
245                     </div>
246                 </div>
247             );
248         }
249     }
250 );
251
252 function traverseFileTree(item, path = '') {
253     return new Promise((resolve) => {
254         if (item.isFile) {
255             item.file((file) => {
256                 file.relativePath = path + file.name;
257                 resolve([file]);
258             });
259         } else if (item.isDirectory) {
260             const dirReader = item.createReader();
261             dirReader.readEntries(async (entries) => {
262                 const files = await Promise.all(entries.map((entry) => traverseFileTree(entry, path + item.name + '/')));
263                 resolve(files.flat());
264             });
265         }
266     });
267 }