15768: fixed navigateTo bug Arvados-DCO-1.1-Signed-off-by: Lisa Knox <lisa.knox@curii...
[arvados-workbench2.git] / src / components / collection-panel-files / collection-panel-files.tsx
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import React from "react";
6 import classNames from "classnames";
7 import { connect } from "react-redux";
8 import { FixedSizeList } from "react-window";
9 import AutoSizer from "react-virtualized-auto-sizer";
10 import servicesProvider from "common/service-provider";
11 import { CustomizeTableIcon, DownloadIcon, 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                             console.log(key);
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]); // 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                 <div className={classes.pathPanel}>
497                     <div className={classes.pathPanelPathWrapper}>
498                         {path.map((p: string, index: number) => (
499                             <span
500                                 key={`${index}-${p}`}
501                                 data-item="true"
502                                 className={classes.pathPanelItem}
503                                 data-breadcrumb-path={p}>
504                                 <span className={classes.rowActive}>{index === 0 ? "Home" : p}</span> <b>/</b>&nbsp;
505                             </span>
506                         ))}
507                     </div>
508                     <Tooltip
509                         className={classes.pathPanelMenu}
510                         title="More options"
511                         disableFocusListener>
512                         <IconButton
513                             data-cy="collection-files-panel-options-btn"
514                             onClick={ev => {
515                                 onOptionsMenuOpen(ev, isWritable);
516                             }}>
517                             <CustomizeTableIcon />
518                         </IconButton>
519                     </Tooltip>
520                 </div>
521                 <div className={classes.wrapper}>
522                     <div
523                         className={classNames(classes.leftPanel, path.length > 1 ? classes.leftPanelVisible : classes.leftPanelHidden)}
524                         data-cy="collection-files-left-panel">
525                         <Tooltip
526                             title="Go back"
527                             className={path.length > 1 ? classes.backButton : classes.backButtonHidden}>
528                             <IconButton onClick={() => setPath(state => [...state.slice(0, state.length - 1)])}>
529                                 <BackIcon />
530                             </IconButton>
531                         </Tooltip>
532                         <div className={path.length > 1 ? classes.searchWrapper : classes.searchWrapperHidden}>
533                             <SearchInput
534                                 selfClearProp={leftKey}
535                                 label="Search"
536                                 value={leftSearch}
537                                 onSearch={setLeftSearch}
538                             />
539                         </div>
540                         <div className={classes.dataWrapper}>
541                             {leftData ? (
542                                 <AutoSizer defaultWidth={0}>
543                                     {({ height, width }) => {
544                                         const filtered = leftData.filter(({ name }) => name.indexOf(leftSearch) > -1);
545                                         return !!filtered.length ? (
546                                             <FixedSizeList
547                                                 height={height}
548                                                 itemCount={filtered.length}
549                                                 itemSize={35}
550                                                 width={width}>
551                                                 {({ index, style }) => {
552                                                     const { id, type, name } = filtered[index];
553                                                     return (
554                                                         <div
555                                                             data-id={id}
556                                                             style={style}
557                                                             data-item="true"
558                                                             data-type={type}
559                                                             data-parent-path={name}
560                                                             className={classNames(classes.row, getActiveClass(name))}
561                                                             key={id}>
562                                                             {getItemIcon(type, getActiveClass(name))}
563                                                             <div className={classes.rowName}>{name}</div>
564                                                             {getActiveClass(name) ? (
565                                                                 <SidePanelRightArrowIcon
566                                                                     style={{ display: "inline", marginTop: "5px", marginLeft: "5px" }}
567                                                                 />
568                                                             ) : null}
569                                                         </div>
570                                                     );
571                                                 }}
572                                             </FixedSizeList>
573                                         ) : (
574                                             <div className={classes.rowEmpty}>No directories available</div>
575                                         );
576                                     }}
577                                 </AutoSizer>
578                             ) : (
579                                 <div
580                                     data-cy="collection-loader"
581                                     className={classes.row}>
582                                     <CircularProgress
583                                         className={classes.loader}
584                                         size={30}
585                                     />
586                                 </div>
587                             )}
588                         </div>
589                     </div>
590                     <div
591                         className={classes.rightPanel}
592                         data-cy="collection-files-right-panel">
593                         <div className={classes.searchWrapper}>
594                             <SearchInput
595                                 selfClearProp={rightKey}
596                                 label="Search"
597                                 value={rightSearch}
598                                 onSearch={setRightSearch}
599                             />
600                         </div>
601                         {isWritable && (
602                             <Button
603                                 className={classes.uploadButton}
604                                 data-cy="upload-button"
605                                 onClick={() => {
606                                     onUploadDataClick(rightKey === leftKey ? undefined : rightKey);
607                                 }}
608                                 variant="contained"
609                                 color="primary"
610                                 size="small">
611                                 <DownloadIcon className={classes.uploadIcon} />
612                                 Upload data
613                             </Button>
614                         )}
615                         <div className={classes.dataWrapper}>
616                             {rightData && !isLoading ? (
617                                 <AutoSizer defaultHeight={500}>
618                                     {({ height, width }) => {
619                                         const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1);
620                                         return !!filtered.length ? (
621                                             <FixedSizeList
622                                                 height={height}
623                                                 itemCount={filtered.length}
624                                                 itemSize={35}
625                                                 width={width}>
626                                                 {({ index, style }) => {
627                                                     const { id, type, name, size } = filtered[index];
628
629                                                     return (
630                                                         <div
631                                                             style={style}
632                                                             data-id={id}
633                                                             data-item="true"
634                                                             data-type={type}
635                                                             data-subfolder-path={name}
636                                                             className={classes.row}
637                                                             key={id}>
638                                                             <Checkbox
639                                                                 color="primary"
640                                                                 className={classes.rowSelection}
641                                                                 checked={collectionPanelFiles[id] ? collectionPanelFiles[id].value.selected : false}
642                                                             />
643                                                             &nbsp;
644                                                             {getItemIcon(type, null)}
645                                                             <div className={classes.rowName}>{name}</div>
646                                                             <span
647                                                                 className={classes.rowName}
648                                                                 style={{
649                                                                     marginLeft: "auto",
650                                                                     marginRight: "1rem",
651                                                                 }}>
652                                                                 {formatFileSize(size)}
653                                                             </span>
654                                                             <Tooltip
655                                                                 title="More options"
656                                                                 disableFocusListener>
657                                                                 <IconButton
658                                                                     data-id="moreOptions"
659                                                                     data-cy="file-item-options-btn"
660                                                                     className={classes.moreOptionsButton}>
661                                                                     <MoreOptionsIcon
662                                                                         data-id="moreOptions"
663                                                                         className={classes.moreOptions}
664                                                                     />
665                                                                 </IconButton>
666                                                             </Tooltip>
667                                                         </div>
668                                                     );
669                                                 }}
670                                             </FixedSizeList>
671                                         ) : (
672                                             <div className={classes.rowEmpty}>This collection is empty</div>
673                                         );
674                                     }}
675                                 </AutoSizer>
676                             ) : (
677                                 <div className={classes.row}>
678                                     <CircularProgress
679                                         className={classes.loader}
680                                         size={30}
681                                     />
682                                 </div>
683                             )}
684                         </div>
685                     </div>
686                 </div>
687             </div>
688         );
689     })
690 );