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