17585: Added search fields, optimized requests
[arvados.git] / src / components / collection-panel-files / collection-panel-files.tsx
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import React from 'react';
6 import classNames from 'classnames';
7 import { connect } from 'react-redux';
8 import { FixedSizeList } from "react-window";
9 import AutoSizer from "react-virtualized-auto-sizer";
10 import { CustomizeTableIcon } from 'components/icon/icon';
11 import { SearchInput } from 'components/search-input/search-input';
12 import { ListItemIcon, StyleRulesCallback, Theme, WithStyles, withStyles, Tooltip, IconButton, Checkbox, CircularProgress } from '@material-ui/core';
13 import { FileTreeData } from '../file-tree/file-tree-data';
14 import { TreeItem, TreeItemStatus } from '../tree/tree';
15 import { RootState } from 'store/store';
16 import { WebDAV, WebDAVRequestConfig } from 'common/webdav';
17 import { AuthState } from 'store/auth/auth-reducer';
18 import { extractFilesData } from 'services/collection-service/collection-service-files-response';
19 import { DefaultIcon, DirectoryIcon, FileIcon } from 'components/icon/icon';
20 import { setCollectionFiles } from 'store/collection-panel/collection-panel-files/collection-panel-files-actions';
21 import { sortBy } from 'lodash';
22
23 export interface CollectionPanelFilesProps {
24     items: any;
25     isWritable: boolean;
26     isLoading: boolean;
27     tooManyFiles: boolean;
28     onUploadDataClick: () => void;
29     onSearchChange: (searchValue: string) => void;
30     onItemMenuOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>, isWritable: boolean) => void;
31     onOptionsMenuOpen: (event: React.MouseEvent<HTMLElement>, isWritable: boolean) => void;
32     onSelectionToggle: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>) => void;
33     onCollapseToggle: (id: string, status: TreeItemStatus) => void;
34     onFileClick: (id: string) => void;
35     loadFilesFunc: () => void;
36     currentItemUuid: any;
37     dispatch: Function;
38     collectionPanelFiles: any;
39     collectionPanel: any;
40 }
41
42 type CssRules = "loader" | "wrapper" | "dataWrapper" | "row" | "rowEmpty" | "leftPanel" | "rightPanel" | "pathPanel" | "pathPanelItem" | "rowName" | "listItemIcon" | "rowActive" | "pathPanelMenu" | "rowSelection" | "leftPanelHidden" | "leftPanelVisible" | "searchWrapper" | "searchWrapperHidden";
43
44 const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
45     wrapper: {
46         display: 'flex',
47         minHeight: '600px',
48         marginBottom: '1rem'
49     },
50     dataWrapper: {
51         minHeight: '500px'
52     },
53     row: {
54         display: 'flex',
55         marginTop: '0.5rem',
56         marginBottom: '0.5rem',
57         cursor: 'pointer',
58         "&:hover": {
59             backgroundColor: 'rgba(0, 0, 0, 0.08)',
60         }
61     },
62     rowEmpty: {
63         top: '40%',
64         width: '100%',
65         textAlign: 'center',
66         position: 'absolute'
67     },
68     loader: {
69         top: '50%',
70         left: '50%',
71         marginTop: '-15px',
72         marginLeft: '-15px',
73         position: 'absolute'
74     },
75     rowName: {
76         display: 'inline-flex',
77         flexDirection: 'column',
78         justifyContent: 'center'
79     },
80     searchWrapper: {
81         width: '100%',
82         marginBottom: '1rem'
83     },
84     searchWrapperHidden: {
85         width: '0px'
86     },
87     rowSelection: {
88         padding: '0px',
89     },
90     rowActive: {
91         color: `${theme.palette.primary.main} !important`,
92     },
93     listItemIcon: {
94         display: 'inline-flex',
95         flexDirection: 'column',
96         justifyContent: 'center'
97     },
98     pathPanelMenu: {
99         float: 'right',
100         marginTop: '-15px',
101     },
102     pathPanel: {
103         padding: '1rem',
104         marginBottom: '1rem',
105         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%)',
106     },
107     leftPanel: {
108         flex: 0,
109         padding: '1rem',
110         marginRight: '1rem',
111         whiteSpace: 'nowrap',
112         position: 'relative',
113         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%)',
114     },
115     leftPanelVisible: {
116         opacity: 1,
117         flex: '30%',
118         animation: `animateVisible 1000ms ${theme.transitions.easing.easeOut}`
119     },
120     leftPanelHidden: {
121         opacity: 0,
122         flex: 'initial',
123         padding: '0',
124         marginRight: '0',
125     },
126     "@keyframes animateVisible": {
127         "0%": {
128             opacity: 0,
129             flex: 'initial',
130         },
131         "100%": {
132             opacity: 1,
133             flex: '30%',
134         }
135     },
136     rightPanel: {
137         flex: '70%',
138         padding: '1rem',
139         position: 'relative',
140         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%)',
141     },
142     pathPanelItem: {
143         cursor: 'pointer',
144     }
145 });
146
147 const pathPromise = {};
148
149 export const CollectionPanelFiles = withStyles(styles)(connect((state: RootState) => ({
150     auth: state.auth,
151     collectionPanel: state.collectionPanel,
152     collectionPanelFiles: state.collectionPanelFiles,
153 }))((props: CollectionPanelFilesProps & WithStyles<CssRules> & { auth: AuthState }) => {
154     const { classes, onItemMenuOpen, isWritable, dispatch, collectionPanelFiles, collectionPanel } = props;
155     const { apiToken, config } = props.auth;
156
157     const webdavClient = new WebDAV();
158     webdavClient.defaults.baseURL = config.keepWebServiceUrl;
159     webdavClient.defaults.headers = {
160         Authorization: `Bearer ${apiToken}`
161     };
162
163     const webDAVRequestConfig: WebDAVRequestConfig = {
164         headers: {
165             Depth: '1',
166         },
167     };
168
169     const parentRef = React.useRef(null);
170     const [path, setPath]: any = React.useState([]);
171     const [pathData, setPathData]: any = React.useState({});
172     const [isLoading, setIsLoading] = React.useState(false);
173     const [rightClickUsed, setRightClickUsed] = React.useState(false);
174     const [leftSearch, setLeftSearch] = React.useState('');
175     const [rightSearch, setRightSearch] = React.useState('');
176
177     const leftKey = (path.length > 1 ? path.slice(0, path.length - 1) : path).join('/');
178     const rightKey = path.join('/');
179
180     const leftData = (pathData[leftKey] || []).filter(({ type }) => type === 'directory');
181     const rightData = pathData[rightKey];
182
183     React.useEffect(() => {
184         if (props.currentItemUuid) {
185             setPathData({});
186             setPath([props.currentItemUuid]);
187         }
188     }, [props.currentItemUuid]);
189
190     const fetchData = (rightKey, ignoreCache = false) => {
191         const dataExists = !!pathData[rightKey];
192         const runningRequest = pathPromise[rightKey];
193
194         if ((!dataExists || ignoreCache) && !runningRequest) {
195             setIsLoading(true);
196
197             webdavClient.propfind(`c=${rightKey}`, webDAVRequestConfig)
198                 .then((request) => {
199                     if (request.responseXML != null) {
200                         const result: any = extractFilesData(request.responseXML);
201                         const sortedResult = sortBy(result, (n) => n.name).sort((n1, n2) => {
202                             if (n1.type === 'directory' && n2.type !== 'directory') {
203                                 return -1;
204                             }
205                             if (n1.type !== 'directory' && n2.type === 'directory') {
206                                 return 1;
207                             }
208                             return 0;
209                         });
210                         const newPathData = { ...pathData, [rightKey]: sortedResult };
211                         setPathData(newPathData);
212                     }
213                 })
214                 .finally(() => {
215                     setIsLoading(false);
216                     delete pathPromise[rightKey];
217                 });
218
219             pathPromise[rightKey] = true;
220         } else {
221             setTimeout(() => setIsLoading(false), 0);
222         }
223     };
224
225     React.useEffect(() => {
226         if (rightKey) {
227             fetchData(rightKey);
228         }
229     }, [rightKey]);
230
231     React.useEffect(() => {
232         const hash = (collectionPanel.item || {}).portableDataHash;
233
234         if (hash && rightClickUsed) {
235             fetchData(rightKey, true);
236         }
237     }, [(collectionPanel.item || {}).portableDataHash]);
238
239     React.useEffect(() => {
240         if (rightData) {
241             setCollectionFiles(rightData, false)(dispatch);
242         }
243     }, [rightData, dispatch]);
244
245     const handleRightClick = React.useCallback(
246         (event) => {
247             event.preventDefault();
248
249             if (!rightClickUsed) {
250                 setRightClickUsed(true);
251             }
252
253             let elem = event.target;
254
255             while (elem && elem.dataset && !elem.dataset.item) {
256                 elem = elem.parentNode;
257             }
258
259             if (!elem) {
260                 return;
261             }
262
263             const { id } = elem.dataset;
264             const item: any = { id, data: rightData.find((elem) => elem.id === id) };
265
266             if (id) {
267                 onItemMenuOpen(event, item, isWritable);
268             }
269         },
270         [onItemMenuOpen, isWritable, rightData]
271     );
272
273     React.useEffect(() => {
274         let node = null;
275
276         if (parentRef && parentRef.current) {
277             node = parentRef.current;
278             (node as any).addEventListener('contextmenu', handleRightClick);
279         }
280
281         return () => {
282             if (node) {
283                 (node as any).removeEventListener('contextmenu', handleRightClick);
284             }
285         };
286     }, [parentRef, handleRightClick]);
287
288     const handleClick = React.useCallback(
289         (event: any) => {
290             let isCheckbox = false;
291             let elem = event.target;
292
293             if (elem.type === 'checkbox') {
294                 isCheckbox = true;
295             }
296
297             while (elem && elem.dataset && !elem.dataset.item) {
298                 elem = elem.parentNode;
299             }
300
301             if (elem && elem.dataset && !isCheckbox) {
302                 const { parentPath, subfolderPath, breadcrumbPath, type } = elem.dataset;
303
304                 if (breadcrumbPath) {
305                     const index = path.indexOf(breadcrumbPath);
306                     setPath([...path.slice(0, index + 1)]);
307                 }
308
309                 if (parentPath) {
310                     if (path.length > 1) {
311                         path.pop()
312                     }
313
314                     setPath([...path, parentPath]);
315                 }
316
317                 if (subfolderPath && type === 'directory') {
318                     setPath([...path, subfolderPath]);
319                 }
320             }
321
322             if (isCheckbox) {
323                 const { id } = elem.dataset;
324                 const item = collectionPanelFiles[id];
325                 props.onSelectionToggle(event, item);
326             }
327         },
328         [path, setPath, collectionPanelFiles]
329     );
330
331     const getItemIcon = React.useCallback(
332         (type: string, activeClass: string | null) => {
333             let Icon = DefaultIcon;
334
335             switch (type) {
336                 case 'directory':
337                     Icon = DirectoryIcon;
338                     break;
339                 case 'file':
340                     Icon = FileIcon;
341                     break;
342             }
343
344             return (
345                 <ListItemIcon className={classNames(classes.listItemIcon, activeClass)}>
346                     <Icon />
347                 </ListItemIcon>
348             )
349         },
350         [classes]
351     );
352
353     const getActiveClass = React.useCallback(
354         (name) => {
355             return path[path.length - 1] === name ? classes.rowActive : null;
356         },
357         [path, classes]
358     );
359
360     const onOptionsMenuOpen = React.useCallback(
361         (ev, isWritable) => {
362             props.onOptionsMenuOpen(ev, isWritable);
363         },
364         [props.onOptionsMenuOpen]
365     );
366
367     return (
368         <div onClick={handleClick} ref={parentRef}>
369             <div className={classes.pathPanel}>
370                 {
371                     path
372                         .map((p: string, index: number) => <span
373                             key={`${index}-${p}`}
374                             data-item="true"
375                             className={classes.pathPanelItem}
376                             data-breadcrumb-path={p}
377                         >
378                             {index === 0 ? 'Home' : p} /&nbsp;
379                         </span>)
380                 }
381                 <Tooltip className={classes.pathPanelMenu} title="More options" disableFocusListener>
382                     <IconButton
383                         data-cy='collection-files-panel-options-btn'
384                         onClick={(ev) => onOptionsMenuOpen(ev, isWritable)}>
385                         <CustomizeTableIcon />
386                     </IconButton>
387                 </Tooltip>
388             </div>
389             <div className={classes.wrapper}>
390                 <div className={classNames(classes.leftPanel, path.length > 1 ? classes.leftPanelVisible : classes.leftPanelHidden)}>
391                     <div className={path.length > 1 ? classes.searchWrapper : classes.searchWrapperHidden}>
392                         <SearchInput label="Search" value={leftSearch} onSearch={setLeftSearch} />
393                     </div>
394                     <div className={classes.dataWrapper}>
395                         {
396                             leftData ?
397                                 <AutoSizer defaultWidth={0}>
398                                     {({ height, width }) => {
399                                         const filtered = leftData.filter(({ name }) => name.indexOf(leftSearch) > -1);
400
401                                         return !!filtered.length ? <FixedSizeList
402                                             height={height}
403                                             itemCount={filtered.length}
404                                             itemSize={35}
405                                             width={width}
406                                         >
407                                             {
408                                                 ({ index, style }) => {
409                                                     const { id, type, name } = filtered[index];
410
411                                                     return <div
412                                                         style={style}
413                                                         data-item="true"
414                                                         data-parent-path={name}
415                                                         className={classNames(classes.row, getActiveClass(name))}
416                                                         key={id}>{getItemIcon(type, getActiveClass(name))} <div className={classes.rowName}>{name}</div>
417                                                     </div>;
418                                                 }
419                                             }
420                                         </FixedSizeList> : <div className={classes.rowEmpty}>No directories available</div>
421                                     }}
422                                 </AutoSizer> : <div className={classes.row}><CircularProgress className={classes.loader} size={30} /></div>
423                         }
424
425                     </div>
426                 </div>
427                 <div className={classes.rightPanel}>
428                     <div className={classes.searchWrapper}>
429                         <SearchInput label="Search" value={rightSearch} onSearch={setRightSearch} />
430                     </div>
431                     <div className={classes.dataWrapper}>
432                         {
433                             rightData && !isLoading ?
434                                 <AutoSizer defaultHeight={500}>
435                                     {({ height, width }) => {
436                                         const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1);
437
438                                         return !!filtered.length ? <FixedSizeList
439                                             height={height}
440                                             itemCount={filtered.length}
441                                             itemSize={35}
442                                             width={width}
443                                         >
444                                             {
445                                                 ({ index, style }) => {
446                                                     const { id, type, name } = filtered[index];
447
448                                                     return <div
449                                                         style={style}
450                                                         data-id={id}
451                                                         data-item="true"
452                                                         data-type={type}
453                                                         data-subfolder-path={name}
454                                                         className={classes.row} key={id}>
455                                                         <Checkbox
456                                                             color="primary"
457                                                             className={classes.rowSelection}
458                                                             checked={collectionPanelFiles[id] ? collectionPanelFiles[id].value.selected : false}
459                                                         />&nbsp;
460                                                     {getItemIcon(type, null)} <div className={classes.rowName}>
461                                                             {name}
462                                                         </div>
463                                                     </div>
464                                                 }
465                                             }
466                                         </FixedSizeList> : <div className={classes.rowEmpty}>No data available</div>
467                                     }}
468                                 </AutoSizer> : <div className={classes.row}><CircularProgress className={classes.loader} size={30} /></div>
469                         }
470                     </div>
471                 </div>
472             </div>
473         </div>
474     );
475 }));