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