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