Merge branch 'main' into 21720-material-ui-upgrade
[arvados.git] / services / workbench2 / src / components / data-explorer / data-explorer.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 { CustomStyleRulesCallback } from 'common/custom-theme';
7 import {
8     Grid,
9     Paper,
10     Toolbar,
11     TablePagination,
12     IconButton,
13     Tooltip,
14     Button,
15     Typography,
16 } from "@mui/material";
17 import { WithStyles } from '@mui/styles';
18 import withStyles from '@mui/styles/withStyles';
19 import { ColumnSelector } from "components/column-selector/column-selector";
20 import { DataTable, DataColumns, DataTableFetchMode } from "components/data-table/data-table";
21 import { DataColumn } from "components/data-table/data-column";
22 import { SearchInput } from "components/search-input/search-input";
23 import { ArvadosTheme } from "common/custom-theme";
24 import { MultiselectToolbar } from "components/multiselect-toolbar/MultiselectToolbar";
25 import { TCheckedList } from "components/data-table/data-table";
26 import { createTree } from "models/tree";
27 import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree";
28 import { CloseIcon, IconType, MaximizeIcon, UnMaximizeIcon, MoreVerticalIcon } from "components/icon/icon";
29 import { PaperProps } from "@mui/material/Paper";
30 import { MPVPanelProps } from "components/multi-panel-view/multi-panel-view";
31 import classNames from "classnames";
32 import { InlinePulser } from "components/loading/inline-pulser";
33
34 type CssRules =
35     | 'titleWrapper'
36     | 'msToolbarStyles'
37     | 'searchBox'
38     | 'headerMenu'
39     | 'toolbar'
40     | 'footer'
41     | 'loadMoreContainer'
42     | 'numResults'
43     | 'root'
44     | 'moreOptionsButton'
45     | 'title'
46     | 'subProcessTitle'
47     | 'dataTable'
48     | 'container'
49     | 'paginationLabel'
50     | 'paginationRoot'
51     | "subToolbarWrapper" 
52     | 'progressWrapper' 
53     | 'progressWrapperNoTitle';
54
55 const styles: CustomStyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
56     titleWrapper: {
57         display: "flex",
58         justifyContent: "space-between",
59     },
60     msToolbarStyles: {
61         paddingTop: "0.6rem",
62     },
63     subToolbarWrapper: {
64         height: "48px",
65         paddingTop: 0,
66         marginBottom: "-20px",
67         marginTop: "-10px",
68         flexShrink: 0,
69     },
70     searchBox: {
71         paddingBottom: 0,
72     },
73     toolbar: {
74         paddingTop: 0,
75         paddingRight: theme.spacing(1),
76         paddingLeft: "10px",
77     },
78     footer: {
79         overflow: "auto",
80     },
81     loadMoreContainer: {
82         minWidth: '8rem',
83     },
84     root: {
85         height: "100%",
86         flex: 1,
87         overflowY: "auto",
88     },
89     moreOptionsButton: {
90         padding: 0,
91     },
92     numResults: {
93         marginTop: 0,
94         fontSize: "10px",
95         marginLeft: "10px",
96         marginBottom: '-0.5rem',
97         minWidth: '8.5rem',
98     },
99     title: {
100         display: "inline-block",
101         paddingLeft: theme.spacing(2),
102         paddingTop: theme.spacing(2),
103         fontSize: "18px",
104         paddingRight: "10px",
105     },
106     subProcessTitle: {
107         display: "inline-block",
108         paddingLeft: theme.spacing(2),
109         paddingTop: theme.spacing(2),
110         fontSize: "18px",
111         flexGrow: 0,
112         paddingRight: "10px",
113     },
114     progressWrapper: {
115         margin: "28px 0 0",
116         flexGrow: 1,
117         flexBasis: "100px",
118     },
119     progressWrapperNoTitle: {
120         paddingLeft: "10px",
121     },
122     dataTable: {
123         height: "100%",
124         overflowY: "auto",
125     },
126     container: {
127         height: "100%",
128     },
129     headerMenu: {
130         marginLeft: "auto",
131         flexBasis: "initial",
132         flexGrow: 0,
133     },
134     paginationLabel: {
135         margin: 0,
136         padding: 0,
137         fontSize: '0.75rem',
138     },
139     paginationRoot: {
140         fontSize: '0.75rem',
141         color: theme.palette.grey["600"],
142     },
143 });
144
145 interface DataExplorerDataProps<T> {
146     fetchMode: DataTableFetchMode;
147     items: T[];
148     itemsAvailable: number;
149     loadingItemsAvailable: boolean;
150     columns: DataColumns<T, any>;
151     searchLabel?: string;
152     searchValue: string;
153     rowsPerPage: number;
154     rowsPerPageOptions: number[];
155     page: number;
156     contextMenuColumn: boolean;
157     defaultViewIcon?: IconType;
158     defaultViewMessages?: string[];
159     working?: boolean;
160     hideColumnSelector?: boolean;
161     paperProps?: PaperProps;
162     actions?: React.ReactNode;
163     hideSearchInput?: boolean;
164     title?: React.ReactNode;
165     progressBar?: React.ReactNode;
166     path?: string;
167     currentRouteUuid: string;
168     selectedResourceUuid: string;
169     elementPath?: string;
170     isMSToolbarVisible: boolean;
171     checkedList: TCheckedList;
172     isNotFound: boolean;
173     searchBarValue: string;
174     paperClassName?: string;
175     forceMultiSelectMode?: boolean;
176 }
177
178 interface DataExplorerActionProps<T> {
179     onSetColumns: (columns: DataColumns<T, any>) => void;
180     onSearch: (value: string) => void;
181     onRowClick: (item: T) => void;
182     onRowDoubleClick: (item: T) => void;
183     onColumnToggle: (column: DataColumn<T, any>) => void;
184     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: T) => void;
185     onSortToggle: (column: DataColumn<T, any>) => void;
186     onFiltersChange: (filters: DataTableFilters, column: DataColumn<T, any>) => void;
187     onPageChange: (page: number) => void;
188     onChangeRowsPerPage: (rowsPerPage: number) => void;
189     onLoadMore: (page: number) => void;
190     extractKey?: (item: T) => React.Key;
191     toggleMSToolbar: (isVisible: boolean) => void;
192     setCheckedListOnStore: (checkedList: TCheckedList) => void;
193     setSelectedUuid: (uuid: string) => void;
194     usesDetailsCard: (uuid: string) => boolean;
195 }
196
197 type DataExplorerProps<T> = DataExplorerDataProps<T> & DataExplorerActionProps<T> & WithStyles<CssRules> & MPVPanelProps;
198
199 export const DataExplorer = withStyles(styles)(
200     class DataExplorerGeneric<T> extends React.Component<DataExplorerProps<T>> {
201         state = {
202             hideToolbar: true,
203         };
204
205         multiSelectToolbarInTitle = !this.props.title && !this.props.progressBar;
206         maxItemsAvailable = 0;
207
208         componentDidMount() {
209             if (this.props.onSetColumns) {
210                 this.props.onSetColumns(this.props.columns);
211             }
212         }
213
214         componentDidUpdate( prevProps: Readonly<DataExplorerProps<T>>, prevState: Readonly<{}>, snapshot?: any ): void {
215             const { selectedResourceUuid, currentRouteUuid, path, usesDetailsCard } = this.props;
216             if(selectedResourceUuid !== prevProps.selectedResourceUuid || currentRouteUuid !== prevProps.currentRouteUuid) {
217                 this.setState({
218                     hideToolbar: usesDetailsCard(path || '') ? selectedResourceUuid === this.props.currentRouteUuid : false,
219                 })
220             }
221             if (this.props.itemsAvailable !== prevProps.itemsAvailable) {
222                 this.maxItemsAvailable = Math.max(this.maxItemsAvailable, this.props.itemsAvailable);
223             }
224             if (this.props.searchBarValue !== prevProps.searchBarValue) {
225                 this.maxItemsAvailable = 0;
226             }
227         }
228
229         render() {
230             const {
231                 columns,
232                 onContextMenu,
233                 onFiltersChange,
234                 onSortToggle,
235                 extractKey,
236                 rowsPerPage,
237                 rowsPerPageOptions,
238                 onColumnToggle,
239                 searchLabel,
240                 searchValue,
241                 onSearch,
242                 items,
243                 itemsAvailable,
244                 loadingItemsAvailable,
245                 onRowClick,
246                 onRowDoubleClick,
247                 classes,
248                 defaultViewIcon,
249                 defaultViewMessages,
250                 hideColumnSelector,
251                 actions,
252                 paperProps,
253                 hideSearchInput,
254                 path,
255                 fetchMode,
256                 selectedResourceUuid,
257                 title,
258                 progressBar,
259                 doHidePanel,
260                 doMaximizePanel,
261                 doUnMaximizePanel,
262                 panelName,
263                 panelMaximized,
264                 elementPath,
265                 toggleMSToolbar,
266                 setCheckedListOnStore,
267                 checkedList,
268                 working,
269                 paperClassName,
270                 forceMultiSelectMode,
271             } = this.props;
272             return (
273                 <Paper
274                     className={classNames(classes.root, paperClassName)}
275                     {...paperProps}
276                     key={path}
277                     data-cy={this.props["data-cy"]}
278                 >
279                     <Grid
280                         container
281                         direction="column"
282                         wrap="nowrap"
283                         className={classes.container}
284                     >
285                         <div data-cy="title-wrapper" className={classes.titleWrapper}>
286                             {title && (
287                                 <Grid
288                                     item
289                                     xs
290                                     className={!!progressBar ? classes.subProcessTitle : classes.title}
291                                 >
292                                     {title}
293                                 </Grid>
294                             )}
295                             {!!progressBar &&
296                                 <div className={classNames({
297                                     [classes.progressWrapper]: true,
298                                     [classes.progressWrapperNoTitle]: !title,
299                                 })}>{progressBar}</div>
300                             }
301                             {this.multiSelectToolbarInTitle && !this.state.hideToolbar && <MultiselectToolbar injectedStyles={classes.msToolbarStyles} />}
302                             {(!hideColumnSelector || !hideSearchInput || !!actions) && (
303                                 <Grid
304                                     className={classes.headerMenu}
305                                     item
306                                     xs
307                                 >
308                                     <Toolbar className={classes.toolbar}>
309                                         <Grid container justifyContent="space-between" wrap="nowrap" alignItems="center">
310                                             {!hideSearchInput && (
311                                                 <div className={classes.searchBox}>
312                                                     {!hideSearchInput && (
313                                                         <SearchInput
314                                                             label={searchLabel}
315                                                             value={searchValue}
316                                                             selfClearProp={""}
317                                                             onSearch={onSearch}
318                                                         />
319                                                     )}
320                                                 </div>
321                                             )}
322                                             {actions}
323                                             {!hideColumnSelector && (
324                                                 <ColumnSelector
325                                                     columns={columns}
326                                                     onColumnToggle={onColumnToggle}
327                                                 />
328                                             )}
329                                         </Grid>
330                                         {doUnMaximizePanel && panelMaximized && (
331                                             <Tooltip
332                                                 title={`Unmaximize ${panelName || "panel"}`}
333                                                 disableFocusListener
334                                             >
335                                                 <IconButton onClick={doUnMaximizePanel} size="large">
336                                                     <UnMaximizeIcon />
337                                                 </IconButton>
338                                             </Tooltip>
339                                         )}
340                                         {doMaximizePanel && !panelMaximized && (
341                                             <Tooltip
342                                                 title={`Maximize ${panelName || "panel"}`}
343                                                 disableFocusListener
344                                             >
345                                                 <IconButton onClick={doMaximizePanel} size="large">
346                                                     <MaximizeIcon />
347                                                 </IconButton>
348                                             </Tooltip>
349                                         )}
350                                         {doHidePanel && (
351                                             <Tooltip
352                                                 title={`Close ${panelName || "panel"}`}
353                                                 disableFocusListener
354                                             >
355                                                 <IconButton disabled={panelMaximized} onClick={doHidePanel} size="large">
356                                                     <CloseIcon />
357                                                 </IconButton>
358                                             </Tooltip>
359                                         )}
360                                     </Toolbar>
361                                 </Grid>
362                             )}
363                         </div>
364                         {!this.multiSelectToolbarInTitle &&
365                             <div className={classes.subToolbarWrapper}>
366                                 {!this.state.hideToolbar && <MultiselectToolbar
367                                     forceMultiSelectMode={forceMultiSelectMode}
368                                 />}
369                             </div>
370                         }
371                         <Grid
372                             item
373                             className={classes.dataTable}
374                         >
375                             <DataTable
376                                 columns={this.props.contextMenuColumn ? [...columns, this.contextMenuColumn] : columns}
377                                 items={items}
378                                 onRowClick={(_, item: T) => onRowClick(item)}
379                                 onContextMenu={onContextMenu}
380                                 onRowDoubleClick={(_, item: T) => onRowDoubleClick(item)}
381                                 onFiltersChange={onFiltersChange}
382                                 onSortToggle={onSortToggle}
383                                 extractKey={extractKey}
384                                 defaultViewIcon={defaultViewIcon}
385                                 defaultViewMessages={defaultViewMessages}
386                                 currentRoute={path}
387                                 toggleMSToolbar={toggleMSToolbar}
388                                 setCheckedListOnStore={setCheckedListOnStore}
389                                 checkedList={checkedList}
390                                 selectedResourceUuid={selectedResourceUuid}
391                                 setSelectedUuid={this.props.setSelectedUuid}
392                                 currentRouteUuid={this.props.currentRouteUuid}
393                                 working={working}
394                                 isNotFound={this.props.isNotFound}
395                             />
396                         </Grid>
397                         <Grid
398                             item
399                             xs
400                         >
401                             <Toolbar className={classes.footer}>
402                                 {elementPath && (
403                                     <Grid container>
404                                         <span data-cy="element-path">{elementPath.length > 2 ? elementPath : ''}</span>
405                                     </Grid>
406                                 )}
407                                 <Grid
408                                     container={!elementPath}
409                                     justifyContent="flex-end"
410                                 >
411                                     {fetchMode === DataTableFetchMode.PAGINATED ? (
412                                         <TablePagination
413                                         data-cy="table-pagination"
414                                             count={itemsAvailable}
415                                             rowsPerPage={rowsPerPage}
416                                             rowsPerPageOptions={rowsPerPageOptions}
417                                             page={this.props.page}
418                                             onPageChange={this.changePage}
419                                             onRowsPerPageChange={this.changeRowsPerPage}
420                                             labelDisplayedRows={renderPaginationLabel(loadingItemsAvailable)}
421                                             nextIconButtonProps={getPaginiationButtonProps(itemsAvailable, loadingItemsAvailable)}
422                                             component="div"
423                                             classes={{ 
424                                                 root: classes.paginationRoot,
425                                                 selectLabel: classes.paginationLabel, 
426                                                 displayedRows: classes.paginationLabel,
427                                             }}
428                                         />
429                                     ) : (
430                                         <Grid className={classes.loadMoreContainer}>
431                                             <Typography  className={classes.numResults}>
432                                                 Showing {items.length} / {this.maxItemsAvailable} results
433                                             </Typography>
434                                             <Button
435                                                 size="small"
436                                                 onClick={this.loadMore}
437                                                 variant="contained"
438                                                 color="primary"
439                                                 style={{width: '100%', margin: '10px'}}
440                                                 disabled={working || items.length >= itemsAvailable}
441                                             >
442                                                 Load more
443                                             </Button>
444                                         </Grid>
445                                     )}
446                                 </Grid>
447                             </Toolbar>
448                         </Grid>
449                     </Grid>
450                 </Paper>
451             );
452         }
453
454         changePage = (event: React.MouseEvent<HTMLButtonElement>, page: number) => {
455             this.props.onPageChange(page);
456         };
457
458         changeRowsPerPage: React.ChangeEventHandler<HTMLTextAreaElement | HTMLInputElement> = event => {
459             this.props.onChangeRowsPerPage(parseInt(event.target.value, 10));
460         };
461
462         loadMore = () => {
463             this.props.onLoadMore(this.props.page + 1);
464         };
465
466         renderContextMenuTrigger = (item: T) => (
467             <Grid
468                 container
469                 justifyContent="center"
470             >
471                 <Tooltip
472                     title="More options"
473                     disableFocusListener
474                 >
475                     <IconButton
476                         className={this.props.classes.moreOptionsButton}
477                         onClick={event => {
478                             event.stopPropagation()
479                             this.props.onContextMenu(event, item)
480                         }}
481                         size="large">
482                         <MoreVerticalIcon />
483                     </IconButton>
484                 </Tooltip>
485             </Grid>
486         );
487
488         contextMenuColumn: DataColumn<any, any> = {
489             name: "Actions",
490             selected: true,
491             configurable: false,
492             filters: createTree(),
493             key: "context-actions",
494             render: this.renderContextMenuTrigger,
495         };
496     }
497 );
498
499 const renderPaginationLabel = (loading: boolean) => ({ from, to, count }) => (
500     loading ?
501         <InlinePulser/>
502         : <>{from}-{to} of {count}</>
503 );
504
505 const getPaginiationButtonProps = (itemsAvailable: number, loading: boolean) => (
506     loading
507         ? { disabled: false } // Always allow paging while loading total
508         : itemsAvailable > 0
509             ? { }
510             : { disabled: true } // Disable next button on empty lists since that's not default behavior
511 );