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