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