Merge branch 'main' into 15768-multi-select-operations Arvados-DCO-1.1-Signed-off...
[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                 .finally(() => {
316                     setIsLoading(false);
317                     keyArray.forEach(key => delete pathPromise[key]);
318                 });
319         };
320
321         React.useEffect(() => {
322             if (rightKey) {
323                 fetchData(rightKey);
324                 setLeftSearch("");
325                 setRightSearch("");
326             }
327         }, [rightKey, rightData]); // eslint-disable-line react-hooks/exhaustive-deps
328
329         const currentPDH = (collectionPanel.item || {}).portableDataHash;
330         React.useEffect(() => {
331             if (currentPDH) {
332                 fetchData([leftKey, rightKey], true);
333             }
334         }, [currentPDH]); // eslint-disable-line react-hooks/exhaustive-deps
335
336         React.useEffect(() => {
337             if (rightData) {
338                 const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1);
339                 setCollectionFiles(filtered, false)(dispatch);
340             }
341         }, [rightData, dispatch, rightSearch]);
342
343         const handleRightClick = React.useCallback(
344             event => {
345                 event.preventDefault();
346                 let elem = event.target;
347
348                 while (elem && elem.dataset && !elem.dataset.item) {
349                     elem = elem.parentNode;
350                 }
351
352                 if (!elem || !elem.dataset) {
353                     return;
354                 }
355
356                 const { id } = elem.dataset;
357
358                 const item: any = {
359                     id,
360                     data: rightData.find(elem => elem.id === id),
361                 };
362
363                 if (id) {
364                     onItemMenuOpen(event, item, isWritable);
365                 }
366             },
367             [onItemMenuOpen, isWritable, rightData]
368         );
369
370         React.useEffect(() => {
371             let node = null;
372
373             if (parentRef?.current) {
374                 node = parentRef.current;
375                 (node as any).addEventListener("contextmenu", handleRightClick);
376             }
377
378             return () => {
379                 if (node) {
380                     (node as any).removeEventListener("contextmenu", handleRightClick);
381                 }
382             };
383         }, [parentRef, handleRightClick]);
384
385         const handleClick = React.useCallback(
386             (event: any) => {
387                 let isCheckbox = false;
388                 let isMoreButton = false;
389                 let elem = event.target;
390
391                 if (elem.type === "checkbox") {
392                     isCheckbox = true;
393                 }
394                 // The "More options" button click event could be triggered on its
395                 // internal graphic element.
396                 else if (
397                     (elem.dataset && elem.dataset.id === "moreOptions") ||
398                     (elem.parentNode && elem.parentNode.dataset && elem.parentNode.dataset.id === "moreOptions")
399                 ) {
400                     isMoreButton = true;
401                 }
402
403                 while (elem && elem.dataset && !elem.dataset.item) {
404                     elem = elem.parentNode;
405                 }
406
407                 if (elem && elem.dataset && !isCheckbox && !isMoreButton) {
408                     const { parentPath, subfolderPath, breadcrumbPath, type } = elem.dataset;
409
410                     if (breadcrumbPath) {
411                         const index = path.indexOf(breadcrumbPath);
412                         setPath(state => [...state.slice(0, index + 1)]);
413                     }
414
415                     if (parentPath && type === "directory") {
416                         if (path.length > 1) {
417                             path.pop();
418                         }
419
420                         setPath(state => [...state, parentPath]);
421                     }
422
423                     if (subfolderPath && type === "directory") {
424                         setPath(state => [...state, subfolderPath]);
425                     }
426
427                     if (elem.dataset.id && type === "file") {
428                         const item = rightData.find(({ id }) => id === elem.dataset.id) || leftData.find(({ id }) => id === elem.dataset.id);
429                         const enhancedItem = servicesProvider.getServices().collectionService.extendFileURL(item);
430                         const fileUrl = sanitizeToken(
431                             getInlineFileUrl(enhancedItem.url, config.keepWebServiceUrl, config.keepWebInlineServiceUrl),
432                             true
433                         );
434                         window.open(fileUrl, "_blank");
435                     }
436                 }
437
438                 if (isCheckbox) {
439                     const { id } = elem.dataset;
440                     const item = collectionPanelFiles[id];
441                     props.onSelectionToggle(event, item);
442                 }
443                 if (isMoreButton) {
444                     const { id } = elem.dataset;
445                     const item: any = {
446                         id,
447                         data: rightData.find(elem => elem.id === id),
448                     };
449                     onItemMenuOpen(event, item, isWritable);
450                 }
451             },
452             [path, setPath, collectionPanelFiles] // eslint-disable-line react-hooks/exhaustive-deps
453         );
454
455         const getItemIcon = React.useCallback(
456             (type: string, activeClass: string | null) => {
457                 let Icon = DefaultIcon;
458
459                 switch (type) {
460                     case "directory":
461                         Icon = DirectoryIcon;
462                         break;
463                     case "file":
464                         Icon = FileIcon;
465                         break;
466                 }
467
468                 return (
469                     <ListItemIcon className={classNames(classes.listItemIcon, activeClass)}>
470                         <Icon />
471                     </ListItemIcon>
472                 );
473             },
474             [classes]
475         );
476
477         const getActiveClass = React.useCallback(
478             name => {
479                 return path[path.length - 1] === name ? classes.rowActive : null;
480             },
481             [path, classes]
482         );
483
484         const onOptionsMenuOpen = React.useCallback(
485             (ev, isWritable) => {
486                 props.onOptionsMenuOpen(ev, isWritable);
487             },
488             [props.onOptionsMenuOpen] // eslint-disable-line react-hooks/exhaustive-deps
489         );
490
491         return (
492             <div
493                 data-cy="collection-files-panel"
494                 onClick={handleClick}
495                 ref={parentRef}
496             >
497                 <div className={classes.pathPanel}>
498                     <div className={classes.pathPanelPathWrapper}>
499                         {path.map((p: string, index: number) => (
500                             <span
501                                 key={`${index}-${p}`}
502                                 data-item="true"
503                                 className={classes.pathPanelItem}
504                                 data-breadcrumb-path={p}
505                             >
506                                 <span className={classes.rowActive}>{index === 0 ? "Home" : p}</span> <b>/</b>&nbsp;
507                             </span>
508                         ))}
509                     </div>
510                     <Tooltip
511                         className={classes.pathPanelMenu}
512                         title="More options"
513                         disableFocusListener
514                     >
515                         <IconButton
516                             data-cy="collection-files-panel-options-btn"
517                             onClick={ev => {
518                                 onOptionsMenuOpen(ev, isWritable);
519                             }}
520                         >
521                             <MoreVerticalIcon />
522                         </IconButton>
523                     </Tooltip>
524                 </div>
525                 <div className={classes.wrapper}>
526                     <div
527                         className={classNames(classes.leftPanel, path.length > 1 ? classes.leftPanelVisible : classes.leftPanelHidden)}
528                         data-cy="collection-files-left-panel"
529                     >
530                         <Tooltip
531                             title="Go back"
532                             className={path.length > 1 ? classes.backButton : classes.backButtonHidden}
533                         >
534                             <IconButton onClick={() => setPath(state => [...state.slice(0, state.length - 1)])}>
535                                 <BackIcon />
536                             </IconButton>
537                         </Tooltip>
538                         <div className={path.length > 1 ? classes.searchWrapper : classes.searchWrapperHidden}>
539                             <SearchInput
540                                 selfClearProp={leftKey}
541                                 label="Search"
542                                 value={leftSearch}
543                                 onSearch={setLeftSearch}
544                             />
545                         </div>
546                         <div className={classes.dataWrapper}>
547                             {leftData ? (
548                                 <AutoSizer defaultWidth={0}>
549                                     {({ height, width }) => {
550                                         const filtered = leftData.filter(({ name }) => name.indexOf(leftSearch) > -1);
551                                         return !!filtered.length ? (
552                                             <FixedSizeList
553                                                 height={height}
554                                                 itemCount={filtered.length}
555                                                 itemSize={35}
556                                                 width={width}
557                                             >
558                                                 {({ index, style }) => {
559                                                     const { id, type, name } = filtered[index];
560                                                     return (
561                                                         <div
562                                                             data-id={id}
563                                                             style={style}
564                                                             data-item="true"
565                                                             data-type={type}
566                                                             data-parent-path={name}
567                                                             className={classNames(classes.row, getActiveClass(name))}
568                                                             key={id}
569                                                         >
570                                                             {getItemIcon(type, getActiveClass(name))}
571                                                             <div className={classes.rowName}>{name}</div>
572                                                             {getActiveClass(name) ? (
573                                                                 <SidePanelRightArrowIcon
574                                                                     style={{ display: "inline", marginTop: "5px", marginLeft: "5px" }}
575                                                                 />
576                                                             ) : null}
577                                                         </div>
578                                                     );
579                                                 }}
580                                             </FixedSizeList>
581                                         ) : (
582                                             <div className={classes.rowEmpty}>No directories available</div>
583                                         );
584                                     }}
585                                 </AutoSizer>
586                             ) : (
587                                 <div
588                                     data-cy="collection-loader"
589                                     className={classes.row}
590                                 >
591                                     <CircularProgress
592                                         className={classes.loader}
593                                         size={30}
594                                     />
595                                 </div>
596                             )}
597                         </div>
598                     </div>
599                     <div
600                         className={classes.rightPanel}
601                         data-cy="collection-files-right-panel"
602                     >
603                         <div className={classes.searchWrapper}>
604                             <SearchInput
605                                 selfClearProp={rightKey}
606                                 label="Search"
607                                 value={rightSearch}
608                                 onSearch={setRightSearch}
609                             />
610                         </div>
611                         {isWritable && (
612                             <Button
613                                 className={classes.uploadButton}
614                                 data-cy="upload-button"
615                                 onClick={() => {
616                                     onUploadDataClick(rightKey === leftKey ? undefined : rightKey);
617                                 }}
618                                 variant="contained"
619                                 color="primary"
620                                 size="small"
621                             >
622                                 <DownloadIcon className={classes.uploadIcon} />
623                                 Upload data
624                             </Button>
625                         )}
626                         <div className={classes.dataWrapper}>
627                             {rightData && !isLoading ? (
628                                 <AutoSizer defaultHeight={500}>
629                                     {({ height, width }) => {
630                                         const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1);
631                                         return !!filtered.length ? (
632                                             <FixedSizeList
633                                                 height={height}
634                                                 itemCount={filtered.length}
635                                                 itemSize={35}
636                                                 width={width}
637                                             >
638                                                 {({ index, style }) => {
639                                                     const { id, type, name, size } = filtered[index];
640
641                                                     return (
642                                                         <div
643                                                             style={style}
644                                                             data-id={id}
645                                                             data-item="true"
646                                                             data-type={type}
647                                                             data-subfolder-path={name}
648                                                             className={classes.row}
649                                                             key={id}
650                                                         >
651                                                             <Checkbox
652                                                                 color="primary"
653                                                                 className={classes.rowSelection}
654                                                                 checked={collectionPanelFiles[id] ? collectionPanelFiles[id].value.selected : false}
655                                                             />
656                                                             &nbsp;
657                                                             {getItemIcon(type, null)}
658                                                             <div className={classes.rowName}>{name}</div>
659                                                             <span
660                                                                 className={classes.rowName}
661                                                                 style={{
662                                                                     marginLeft: "auto",
663                                                                     marginRight: "1rem",
664                                                                 }}
665                                                             >
666                                                                 {formatFileSize(size)}
667                                                             </span>
668                                                             <Tooltip
669                                                                 title="More options"
670                                                                 disableFocusListener
671                                                             >
672                                                                 <IconButton
673                                                                     data-id="moreOptions"
674                                                                     data-cy="file-item-options-btn"
675                                                                     className={classes.moreOptionsButton}
676                                                                 >
677                                                                     <MoreHorizontalIcon
678                                                                         data-id="moreOptions"
679                                                                         className={classes.moreOptions}
680                                                                     />
681                                                                 </IconButton>
682                                                             </Tooltip>
683                                                         </div>
684                                                     );
685                                                 }}
686                                             </FixedSizeList>
687                                         ) : (
688                                             <div className={classes.rowEmpty}>This collection is empty</div>
689                                         );
690                                     }}
691                                 </AutoSizer>
692                             ) : (
693                                 <div className={classes.row}>
694                                     <CircularProgress
695                                         className={classes.loader}
696                                         size={30}
697                                     />
698                                 </div>
699                             )}
700                         </div>
701                     </div>
702                 </div>
703             </div>
704         );
705     })
706 );