18787: Fix post rebase
[arvados-workbench2.git] / src / components / collection-panel-files / collection-panel-files.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 { connect } from 'react-redux';
8 import { FixedSizeList } from "react-window";
9 import AutoSizer from "react-virtualized-auto-sizer";
10 import servicesProvider from 'common/service-provider';
11 import { CustomizeTableIcon, DownloadIcon } from 'components/icon/icon';
12 import { SearchInput } from 'components/search-input/search-input';
13 import { ListItemIcon, StyleRulesCallback, Theme, WithStyles, withStyles, Tooltip, IconButton, Checkbox, CircularProgress, Button } from '@material-ui/core';
14 import { FileTreeData } from '../file-tree/file-tree-data';
15 import { TreeItem, TreeItemStatus } from '../tree/tree';
16 import { RootState } from 'store/store';
17 import { WebDAV, WebDAVRequestConfig } from 'common/webdav';
18 import { AuthState } from 'store/auth/auth-reducer';
19 import { extractFilesData } from 'services/collection-service/collection-service-files-response';
20 import { DefaultIcon, DirectoryIcon, FileIcon, BackIcon, SidePanelRightArrowIcon } from 'components/icon/icon';
21 import { setCollectionFiles } from 'store/collection-panel/collection-panel-files/collection-panel-files-actions';
22 import { sortBy } from 'lodash';
23 import { formatFileSize } from 'common/formatters';
24 import { getInlineFileUrl, sanitizeToken } from 'views-components/context-menu/actions/helpers';
25 import _ from 'lodash';
26
27 export interface CollectionPanelFilesProps {
28     items: any;
29     isWritable: boolean;
30     onUploadDataClick: (targetLocation?: string) => void;
31     onSearchChange: (searchValue: string) => void;
32     onItemMenuOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>, isWritable: boolean) => void;
33     onOptionsMenuOpen: (event: React.MouseEvent<HTMLElement>, isWritable: boolean) => void;
34     onSelectionToggle: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>) => void;
35     onCollapseToggle: (id: string, status: TreeItemStatus) => void;
36     onFileClick: (id: string) => void;
37     currentItemUuid: any;
38     dispatch: Function;
39     collectionPanelFiles: any;
40     collectionPanel: any;
41 }
42
43 type CssRules = "backButton" | "backButtonHidden" | "pathPanelPathWrapper" | "uploadButton" | "uploadIcon" | "loader" | "wrapper" | "dataWrapper" | "row" | "rowEmpty" | "leftPanel" | "rightPanel" | "pathPanel" | "pathPanelItem" | "rowName" | "listItemIcon" | "rowActive" | "pathPanelMenu" | "rowSelection" | "leftPanelHidden" | "leftPanelVisible" | "searchWrapper" | "searchWrapperHidden";
44
45 const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
46     wrapper: {
47         display: 'flex',
48         minHeight: '600px',
49         color: 'rgba(0, 0, 0, 0.87)',
50         fontSize: '0.875rem',
51         fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
52         fontWeight: 400,
53         lineHeight: '1.5',
54         letterSpacing: '0.01071em'
55     },
56     backButton: {
57         color: '#00bfa5',
58         cursor: 'pointer',
59         float: 'left',
60     },
61     backButtonHidden: {
62         display: 'none',
63     },
64     dataWrapper: {
65         minHeight: '500px'
66     },
67     row: {
68         display: 'flex',
69         marginTop: '0.5rem',
70         marginBottom: '0.5rem',
71         cursor: 'pointer',
72         "&:hover": {
73             backgroundColor: 'rgba(0, 0, 0, 0.08)',
74         }
75     },
76     rowEmpty: {
77         top: '40%',
78         width: '100%',
79         textAlign: 'center',
80         position: 'absolute'
81     },
82     loader: {
83         top: '50%',
84         left: '50%',
85         marginTop: '-15px',
86         marginLeft: '-15px',
87         position: 'absolute'
88     },
89     rowName: {
90         display: 'inline-flex',
91         flexDirection: 'column',
92         justifyContent: 'center'
93     },
94     searchWrapper: {
95         display: 'inline-block',
96         marginBottom: '1rem',
97         marginLeft: '1rem',
98     },
99     searchWrapperHidden: {
100         width: '0px'
101     },
102     rowSelection: {
103         padding: '0px',
104     },
105     rowActive: {
106         color: `${theme.palette.primary.main} !important`,
107     },
108     listItemIcon: {
109         display: 'inline-flex',
110         flexDirection: 'column',
111         justifyContent: 'center'
112     },
113     pathPanelMenu: {
114         float: 'right',
115         marginTop: '-15px',
116     },
117     pathPanel: {
118         padding: '1rem',
119         marginBottom: '1rem',
120         backgroundColor: '#fff',
121         boxShadow: '0px 1px 3px 0px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 2px 1px -1px rgb(0 0 0 / 12%)',
122     },
123     pathPanelPathWrapper: {
124         display: 'inline-block',
125     },
126     leftPanel: {
127         flex: 0,
128         padding: '1rem',
129         marginRight: '1rem',
130         whiteSpace: 'nowrap',
131         position: 'relative',
132         backgroundColor: '#fff',
133         boxShadow: '0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)',
134     },
135     leftPanelVisible: {
136         opacity: 1,
137         flex: '50%',
138         animation: `animateVisible 1000ms ${theme.transitions.easing.easeOut}`
139     },
140     leftPanelHidden: {
141         opacity: 0,
142         flex: 'initial',
143         padding: '0',
144         marginRight: '0',
145     },
146     "@keyframes animateVisible": {
147         "0%": {
148             opacity: 0,
149             flex: 'initial',
150         },
151         "100%": {
152             opacity: 1,
153             flex: '50%',
154         }
155     },
156     rightPanel: {
157         flex: '50%',
158         padding: '1rem',
159         paddingTop: '2rem',
160         marginTop: '-1rem',
161         position: 'relative',
162         backgroundColor: '#fff',
163         boxShadow: '0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)',
164     },
165     pathPanelItem: {
166         cursor: 'pointer',
167     },
168     uploadIcon: {
169         transform: 'rotate(180deg)'
170     },
171     uploadButton: {
172         float: 'right',
173     }
174 });
175
176 const pathPromise = {};
177
178 let prevState = {};
179 function difference(object, base) {
180         function changes(object, base) {
181                 return _.transform(object, function(result, value, key) {
182                         if (!_.isEqual(value, base[key])) {
183                                 result[key] = (_.isObject(value) && _.isObject(base[key])) ? changes(value, base[key]) : value;
184                         }
185                 });
186         }
187         return changes(object, base);
188 }
189 export const CollectionPanelFiles = withStyles(styles)(connect((state: RootState) => ({
190     auth: state.auth,
191     collectionPanel: state.collectionPanel,
192     collectionPanelFiles: state.collectionPanelFiles,
193 }))((props: CollectionPanelFilesProps & WithStyles<CssRules> & { auth: AuthState }) => {
194     const diff = difference(props, prevState);
195     prevState = props;
196     console.log('---> render CollectionPanel <------', diff);
197     const { classes, onItemMenuOpen, onUploadDataClick, isWritable, dispatch, collectionPanelFiles, collectionPanel } = props;
198     const { apiToken, config } = props.auth;
199
200     const webdavClient = new WebDAV();
201     webdavClient.defaults.baseURL = config.keepWebServiceUrl;
202     webdavClient.defaults.headers = {
203         Authorization: `Bearer ${apiToken}`
204     };
205
206     const webDAVRequestConfig: WebDAVRequestConfig = {
207         headers: {
208             Depth: '1',
209         },
210     };
211
212     const parentRef = React.useRef(null);
213     const [path, setPath]: any = React.useState([]);
214     const [pathData, setPathData]: any = React.useState({});
215     const [isLoading, setIsLoading] = React.useState(false);
216     const [leftSearch, setLeftSearch] = React.useState('');
217     const [rightSearch, setRightSearch] = React.useState('');
218
219     const leftKey = (path.length > 1 ? path.slice(0, path.length - 1) : path).join('/');
220     const rightKey = path.join('/');
221
222     const leftData = pathData[leftKey] || [];
223     const rightData = pathData[rightKey];
224
225     React.useEffect(() => {
226         if (props.currentItemUuid) {
227             console.log(' --> useEffect current UUID: ', props.currentItemUuid);
228             setPathData({});
229             setPath([props.currentItemUuid]);
230         }
231     }, [props.currentItemUuid]);
232
233     const fetchData = (keys, ignoreCache = false) => {
234         console.log('---> fetchData', keys);
235         const keyArray = Array.isArray(keys) ? keys : [keys];
236
237         Promise.all(keyArray.filter(key => !!key)
238             .map((key) => {
239                 const dataExists = !!pathData[key];
240                 const runningRequest = pathPromise[key];
241
242                 if ((!dataExists || ignoreCache) && (!runningRequest || ignoreCache)) {
243                     if (!isLoading) {
244                         setIsLoading(true);
245                     }
246
247                     pathPromise[key] = true;
248
249                     console.log('>>> fetching data for key', key);
250                     return webdavClient.propfind(`c=${key}`, webDAVRequestConfig);
251                 }
252
253                 return Promise.resolve(null);
254             })
255             .filter((promise) => !!promise)
256         )
257         .then((requests) => {
258             const newState = requests.map((request, index) => {
259                 if (request && request.responseXML != null) {
260                     console.log(">>> got data for key", keyArray[index]);
261                     const key = keyArray[index];
262                     const result: any = extractFilesData(request.responseXML);
263                     const sortedResult = sortBy(result, (n) => n.name).sort((n1, n2) => {
264                         if (n1.type === 'directory' && n2.type !== 'directory') {
265                             return -1;
266                         }
267                         if (n1.type !== 'directory' && n2.type === 'directory') {
268                             return 1;
269                         }
270                         return 0;
271                     });
272
273                     return { [key]: sortedResult };
274                 }
275                 return {};
276             }).reduce((prev, next) => {
277                 return { ...next, ...prev };
278             }, {});
279
280             setPathData({ ...pathData, ...newState });
281         })
282         .finally(() => {
283             setIsLoading(false);
284             keyArray.forEach(key => delete pathPromise[key]);
285         });
286     };
287
288     React.useEffect(() => {
289         if (rightKey) {
290             console.log('---> useEffect rightKey:', rightKey);
291             fetchData(rightKey);
292             setLeftSearch('');
293             setRightSearch('');
294         }
295     }, [rightKey]); // eslint-disable-line react-hooks/exhaustive-deps
296
297     const currentPDH = (collectionPanel.item || {}).portableDataHash;
298     React.useEffect(() => {
299         if (currentPDH) {
300             console.log('---> useEffect PDH change:', currentPDH);
301             // Avoid fetching the same content level twice
302             if (leftKey !== rightKey) {
303                 fetchData([leftKey, rightKey], true);
304             } else {
305                 fetchData(rightKey, true);
306             }
307         }
308     }, [currentPDH]); // eslint-disable-line react-hooks/exhaustive-deps
309
310     React.useEffect(() => {
311         if (rightData) {
312             console.log('---> useEffect rightData:', rightData, 'search:', rightSearch);
313             const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1);
314             setCollectionFiles(filtered, false)(dispatch);
315         }
316     }, [rightData, dispatch, rightSearch]);
317
318     const handleRightClick = React.useCallback(
319         (event) => {
320             event.preventDefault();
321             let elem = event.target;
322
323             while (elem && elem.dataset && !elem.dataset.item) {
324                 elem = elem.parentNode;
325             }
326
327             if (!elem || !elem.dataset) {
328                 return;
329             }
330
331             const { id } = elem.dataset;
332
333             const item: any = {
334                 id,
335                 data: rightData.find((elem) => elem.id === id),
336             };
337
338             if (id) {
339                 onItemMenuOpen(event, item, isWritable);
340             }
341         },
342         [onItemMenuOpen, isWritable, rightData] // eslint-disable-line react-hooks/exhaustive-deps
343     );
344
345     React.useEffect(() => {
346         let node = null;
347
348         if (parentRef && parentRef.current) {
349             console.log('---> useEffect parentRef:', parentRef);
350             node = parentRef.current;
351             (node as any).addEventListener('contextmenu', handleRightClick);
352         }
353
354         return () => {
355             if (node) {
356                 (node as any).removeEventListener('contextmenu', handleRightClick);
357             }
358         };
359     }, [parentRef, handleRightClick]);
360
361     const handleClick = React.useCallback(
362         (event: any) => {
363             let isCheckbox = false;
364             let elem = event.target;
365
366             if (elem.type === 'checkbox') {
367                 isCheckbox = true;
368             }
369
370             while (elem && elem.dataset && !elem.dataset.item) {
371                 elem = elem.parentNode;
372             }
373
374             if (elem && elem.dataset && !isCheckbox) {
375                 const { parentPath, subfolderPath, breadcrumbPath, type } = elem.dataset;
376
377                 if (breadcrumbPath) {
378                     const index = path.indexOf(breadcrumbPath);
379                     setPath([...path.slice(0, index + 1)]);
380                 }
381
382                 if (parentPath && type === 'directory') {
383                     if (path.length > 1) {
384                         path.pop()
385                     }
386
387                     setPath([...path, parentPath]);
388                 }
389
390                 if (subfolderPath && type === 'directory') {
391                     setPath([...path, subfolderPath]);
392                 }
393
394                 if (elem.dataset.id && type === 'file') {
395                     const item = rightData.find(({id}) => id === elem.dataset.id) || leftData.find(({ id }) => id === elem.dataset.id);
396                     const enhancedItem = servicesProvider.getServices().collectionService.extendFileURL(item);
397                     const fileUrl = sanitizeToken(getInlineFileUrl(enhancedItem.url, config.keepWebServiceUrl, config.keepWebInlineServiceUrl), true);
398                     window.open(fileUrl, '_blank');
399                 }
400             }
401
402             if (isCheckbox) {
403                 const { id } = elem.dataset;
404                 const item = collectionPanelFiles[id];
405                 props.onSelectionToggle(event, item);
406             }
407         },
408         [path, setPath, collectionPanelFiles] // eslint-disable-line react-hooks/exhaustive-deps
409     );
410
411     const getItemIcon = React.useCallback(
412         (type: string, activeClass: string | null) => {
413             let Icon = DefaultIcon;
414
415             switch (type) {
416                 case 'directory':
417                     Icon = DirectoryIcon;
418                     break;
419                 case 'file':
420                     Icon = FileIcon;
421                     break;
422             }
423
424             return (
425                 <ListItemIcon className={classNames(classes.listItemIcon, activeClass)}>
426                     <Icon />
427                 </ListItemIcon>
428             )
429         },
430         [classes]
431     );
432
433     const getActiveClass = React.useCallback(
434         (name) => {
435             return path[path.length - 1] === name ? classes.rowActive : null;
436         },
437         [path, classes]
438     );
439
440     const onOptionsMenuOpen = React.useCallback(
441         (ev, isWritable) => {
442             props.onOptionsMenuOpen(ev, isWritable);
443         },
444         [props.onOptionsMenuOpen] // eslint-disable-line react-hooks/exhaustive-deps
445     );
446
447     return <div data-cy="collection-files-panel" onClick={handleClick} ref={parentRef}>
448         <div className={classes.pathPanel}>
449             <div className={classes.pathPanelPathWrapper}>
450                 { path.map((p: string, index: number) => <span
451                     key={`${index}-${p}`}
452                     data-item="true"
453                     className={classes.pathPanelItem}
454                     data-breadcrumb-path={p}
455                 >
456                     <span className={classes.rowActive}>{index === 0 ? 'Home' : p}</span> <b>/</b>&nbsp;
457                 </span>) }
458             </div>
459             <Tooltip className={classes.pathPanelMenu} title="More options" disableFocusListener>
460                 <IconButton
461                     data-cy='collection-files-panel-options-btn'
462                     onClick={(ev) => {
463                         onOptionsMenuOpen(ev, isWritable);
464                     }}>
465                     <CustomizeTableIcon />
466                 </IconButton>
467             </Tooltip>
468         </div>
469         <div className={classes.wrapper}>
470             <div className={classNames(classes.leftPanel, path.length > 1 ? classes.leftPanelVisible : classes.leftPanelHidden)}  data-cy="collection-files-left-panel">
471                 <Tooltip title="Go back" className={path.length > 1 ? classes.backButton : classes.backButtonHidden}>
472                     <IconButton onClick={() => setPath([...path.slice(0, path.length -1)])}>
473                         <BackIcon />
474                     </IconButton>
475                 </Tooltip>
476             </div>
477             <div className={classes.wrapper}>
478                 <div className={classNames(classes.leftPanel, path.length > 1 ? classes.leftPanelVisible : classes.leftPanelHidden)}  data-cy="collection-files-left-panel">
479                     <Tooltip title="Go back" className={path.length > 1 ? classes.backButton : classes.backButtonHidden}>
480                         <IconButton onClick={() => setPath([...path.slice(0, path.length -1)])}>
481                             <BackIcon />
482                         </IconButton>
483                     </Tooltip>
484                     <div className={path.length > 1 ? classes.searchWrapper : classes.searchWrapperHidden}>
485                         <SearchInput selfClearProp={leftKey} label="Search" value={leftSearch} onSearch={setLeftSearch} />
486                     </div>
487                     <div className={classes.dataWrapper}>
488                         {
489                             leftData ?
490                                 <AutoSizer defaultWidth={0}>
491                                     {({ height, width }) => {
492                                         const filtered = leftData.filter(({ name }) => name.indexOf(leftSearch) > -1);
493
494                                         return !!filtered.length ? <FixedSizeList
495                                             height={height}
496                                             itemCount={filtered.length}
497                                             itemSize={35}
498                                             width={width}
499                                         >
500                                             {
501                                                 ({ index, style }) => {
502                                                     const { id, type, name } = filtered[index];
503
504                                                     return <div
505                                                         data-id={id}
506                                                         style={style}
507                                                         data-item="true"
508                                                         data-type={type}
509                                                         data-parent-path={name}
510                                                         className={classNames(classes.row, getActiveClass(name))}
511                                                         key={id}>
512                                                             {getItemIcon(type, getActiveClass(name))}
513                                                             <div className={classes.rowName}>
514                                                                 {name}
515                                                             </div>
516                                                             {
517                                                                 getActiveClass(name) ? <SidePanelRightArrowIcon
518                                                                     style={{ display: 'inline', marginTop: '5px', marginLeft: '5px' }} /> : null
519                                                             }
520                                                     </div>;
521                                                 }
522                                             }
523                                         </FixedSizeList> : <div className={classes.rowEmpty}>No directories available</div>
524                                     }}
525                                 </AutoSizer> : <div className={classes.row}><CircularProgress className={classes.loader} size={30} /></div>
526                         }
527
528                     </div>
529                 </div>
530                 <div className={classes.rightPanel} data-cy="collection-files-right-panel">
531                     <div className={classes.searchWrapper}>
532                         <SearchInput selfClearProp={rightKey} label="Search" value={rightSearch} onSearch={setRightSearch} />
533                     </div>
534                     {
535                         isWritable &&
536                         <Button
537                             className={classes.uploadButton}
538                             data-cy='upload-button'
539                             onClick={() => {
540                                 onUploadDataClick(rightKey === leftKey ? undefined : rightKey);
541                             }}
542                             variant='contained'
543                             color='primary'
544                             size='small'>
545                             <DownloadIcon className={classes.uploadIcon} />
546                             Upload data
547                         </Button>
548                     }
549                     <div className={classes.dataWrapper}>
550                         {
551                             rightData && !isLoading ?
552                                 <AutoSizer defaultHeight={500}>
553                                     {({ height, width }) => {
554                                         const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1);
555
556                         return !!filtered.length
557                         ? <FixedSizeList height={height} itemCount={filtered.length}
558                             itemSize={35} width={width}>{ ({ index, style }) => {
559                             console.log("Left Data ROW: ", filtered[index]);
560                             const { id, type, name } = filtered[index];
561
562                             return <div data-id={id} style={style} data-item="true"
563                                 data-type={type} data-parent-path={name} key={id}
564                                 className={classNames(classes.row, getActiveClass(name))}>
565                                     { getItemIcon(type, getActiveClass(name)) }
566                                     <div className={classes.rowName}>
567                                         {name}
568                                     </div>
569                                     { getActiveClass(name)
570                                     ? <SidePanelRightArrowIcon style={{
571                                         display: 'inline',
572                                         marginTop: '5px',
573                                         marginLeft: '5px' }} />
574                                     : null }
575                             </div>;
576                         } }</FixedSizeList>
577                         : <div className={classes.rowEmpty}>No directories available</div>
578                     } }</AutoSizer>
579                     : <div className={classes.row}>
580                         <CircularProgress className={classes.loader} size={30} />
581                     </div> }
582                 </div>
583             </div>
584             <div className={classes.rightPanel}>
585                 <div className={classes.searchWrapper}>
586                     <SearchInput selfClearProp={rightKey} label="Search" value={rightSearch} onSearch={setRightSearch} />
587                 </div>
588                 { isWritable &&
589                 <Button className={classes.uploadButton} data-cy='upload-button'
590                     onClick={() => {
591                         onUploadDataClick();
592                     }}
593                     variant='contained' color='primary' size='small'>
594                     <DownloadIcon className={classes.uploadIcon} />
595                     Upload data
596                 </Button> }
597                 <div className={classes.dataWrapper}>{ rightData && !isLoading
598                     ? <AutoSizer defaultHeight={500}>{({ height, width }) => {
599                         const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1);
600                         console.log("Right Data: ", filtered);
601
602                         return !!filtered.length
603                         ? <FixedSizeList height={height} itemCount={filtered.length}
604                             itemSize={35} width={width}>{ ({ index, style }) => {
605                                 console.log("Right Data ROW: ", filtered[index]);
606                                 const { id, type, name, size } = filtered[index];
607
608                                 return <div style={style} data-id={id} data-item="true"
609                                     data-type={type} data-subfolder-path={name}
610                                     className={classes.row} key={id}>
611                                     <Checkbox color="primary"
612                                         className={classes.rowSelection}
613                                         checked={collectionPanelFiles[id] ? collectionPanelFiles[id].value.selected : false}
614                                     />&nbsp;
615                                     {getItemIcon(type, null)}
616                                     <div className={classes.rowName}>
617                                         {name}
618                                     </div>
619                                     <span className={classes.rowName} style={{
620                                         marginLeft: 'auto', marginRight: '1rem' }}>
621                                         { formatFileSize(size) }
622                                     </span>
623                                 </div>
624                             } }</FixedSizeList>
625                         : <div className={classes.rowEmpty}>This collection is empty</div>
626                     }}</AutoSizer>
627                     : <div className={classes.row}>
628                         <CircularProgress className={classes.loader} size={30} />
629                     </div> }
630                 </div>
631             </div>
632         </div>
633     </div></div>}));