Merge branch '21128-toolbar-context-menu'
[arvados-workbench2.git] / 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 { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, WithStyles, TablePagination, IconButton, Tooltip, Button } from "@material-ui/core";
7 import { ColumnSelector } from "components/column-selector/column-selector";
8 import { DataTable, DataColumns, DataTableFetchMode } from "components/data-table/data-table";
9 import { DataColumn } from "components/data-table/data-column";
10 import { SearchInput } from "components/search-input/search-input";
11 import { ArvadosTheme } from "common/custom-theme";
12 import { MultiselectToolbar } from "components/multiselect-toolbar/MultiselectToolbar";
13 import { TCheckedList } from "components/data-table/data-table";
14 import { createTree } from "models/tree";
15 import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree";
16 import { CloseIcon, IconType, MaximizeIcon, UnMaximizeIcon, MoreVerticalIcon } from "components/icon/icon";
17 import { PaperProps } from "@material-ui/core/Paper";
18 import { MPVPanelProps } from "components/multi-panel-view/multi-panel-view";
19
20 type CssRules = "titleWrapper" | "searchBox" | "headerMenu" | "toolbar" | "footer" | "root" | "moreOptionsButton" | "title" | 'subProcessTitle' | "dataTable" | "container";
21
22 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
23     titleWrapper: {
24         display: "flex",
25         justifyContent: "space-between",
26     },
27     searchBox: {
28         paddingBottom: 0,
29     },
30     toolbar: {
31         paddingTop: 0,
32         paddingRight: theme.spacing.unit,
33         paddingLeft: "10px",
34     },
35     footer: {
36         overflow: "auto",
37     },
38     root: {
39         height: "100%",
40     },
41     moreOptionsButton: {
42         padding: 0,
43     },
44     title: {
45         display: "inline-block",
46         paddingLeft: theme.spacing.unit * 2,
47         paddingTop: theme.spacing.unit * 2,
48         fontSize: "18px",
49         paddingRight: "10px",
50     },
51     subProcessTitle: {
52         display: "inline-block",
53         paddingLeft: theme.spacing.unit * 2,
54         paddingTop: theme.spacing.unit * 2,
55         fontSize: "18px",
56         flexGrow: 0,
57         paddingRight: "10px",
58     },
59     dataTable: {
60         height: "100%",
61         overflow: "auto",
62     },
63     container: {
64         height: "100%",
65     },
66     headerMenu: {
67         marginLeft: "auto",
68         flexBasis: "initial",
69         flexGrow: 0,
70     },
71 });
72
73 interface DataExplorerDataProps<T> {
74     fetchMode: DataTableFetchMode;
75     items: T[];
76     itemsAvailable: number;
77     columns: DataColumns<T, any>;
78     searchLabel?: string;
79     searchValue: string;
80     rowsPerPage: number;
81     rowsPerPageOptions: number[];
82     page: number;
83     contextMenuColumn: boolean;
84     defaultViewIcon?: IconType;
85     defaultViewMessages?: string[];
86     working?: boolean;
87     currentRefresh?: string;
88     currentRoute?: string;
89     hideColumnSelector?: boolean;
90     paperProps?: PaperProps;
91     actions?: React.ReactNode;
92     hideSearchInput?: boolean;
93     title?: React.ReactNode;
94     progressBar?: React.ReactNode;
95     paperKey?: string;
96     currentItemUuid: string;
97     elementPath?: string;
98     isMSToolbarVisible: boolean;
99     checkedList: TCheckedList;
100 }
101
102 interface DataExplorerActionProps<T> {
103     onSetColumns: (columns: DataColumns<T, any>) => void;
104     onSearch: (value: string) => void;
105     onRowClick: (item: T) => void;
106     onRowDoubleClick: (item: T) => void;
107     onColumnToggle: (column: DataColumn<T, any>) => void;
108     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: T) => void;
109     onSortToggle: (column: DataColumn<T, any>) => void;
110     onFiltersChange: (filters: DataTableFilters, column: DataColumn<T, any>) => void;
111     onChangePage: (page: number) => void;
112     onChangeRowsPerPage: (rowsPerPage: number) => void;
113     onLoadMore: (page: number) => void;
114     extractKey?: (item: T) => React.Key;
115     toggleMSToolbar: (isVisible: boolean) => void;
116     setCheckedListOnStore: (checkedList: TCheckedList) => void;
117 }
118
119 type DataExplorerProps<T> = DataExplorerDataProps<T> & DataExplorerActionProps<T> & WithStyles<CssRules> & MPVPanelProps;
120
121 export const DataExplorer = withStyles(styles)(
122     class DataExplorerGeneric<T> extends React.Component<DataExplorerProps<T>> {
123         state = {
124             showLoading: false,
125             prevRefresh: "",
126             prevRoute: "",
127         };
128
129         multiSelectToolbarInTitle = !this.props.title && !this.props.progressBar;
130
131         componentDidUpdate(prevProps: DataExplorerProps<T>) {
132             const currentRefresh = this.props.currentRefresh || "";
133             const currentRoute = this.props.currentRoute || "";
134
135             if (currentRoute !== this.state.prevRoute) {
136                 // Component already mounted, but the user comes from a route change,
137                 // like browsing through a project hierarchy.
138                 this.setState({
139                     showLoading: this.props.working,
140                     prevRoute: currentRoute,
141                 });
142             }
143
144             if (currentRefresh !== this.state.prevRefresh) {
145                 // Component already mounted, but the user just clicked the
146                 // refresh button.
147                 this.setState({
148                     showLoading: this.props.working,
149                     prevRefresh: currentRefresh,
150                 });
151             }
152             if (this.state.showLoading && !this.props.working) {
153                 this.setState({
154                     showLoading: false,
155                 });
156             }
157         }
158
159         componentDidMount() {
160             if (this.props.onSetColumns) {
161                 this.props.onSetColumns(this.props.columns);
162             }
163             // Component just mounted, so we need to show the loading indicator.
164             this.setState({
165                 showLoading: this.props.working,
166                 prevRefresh: this.props.currentRefresh || "",
167                 prevRoute: this.props.currentRoute || "",
168             });
169         }
170
171         render() {
172             const {
173                 columns,
174                 onContextMenu,
175                 onFiltersChange,
176                 onSortToggle,
177                 extractKey,
178                 rowsPerPage,
179                 rowsPerPageOptions,
180                 onColumnToggle,
181                 searchLabel,
182                 searchValue,
183                 onSearch,
184                 items,
185                 itemsAvailable,
186                 onRowClick,
187                 onRowDoubleClick,
188                 classes,
189                 defaultViewIcon,
190                 defaultViewMessages,
191                 hideColumnSelector,
192                 actions,
193                 paperProps,
194                 hideSearchInput,
195                 paperKey,
196                 fetchMode,
197                 currentItemUuid,
198                 currentRoute,
199                 title,
200                 progressBar,
201                 doHidePanel,
202                 doMaximizePanel,
203                 doUnMaximizePanel,
204                 panelName,
205                 panelMaximized,
206                 elementPath,
207                 toggleMSToolbar,
208                 setCheckedListOnStore,
209                 checkedList,
210             } = this.props;
211             return (
212                 <Paper
213                     className={classes.root}
214                     {...paperProps}
215                     key={paperKey}
216                     data-cy={this.props["data-cy"]}
217                 >
218                     <Grid
219                         container
220                         direction="column"
221                         wrap="nowrap"
222                         className={classes.container}
223                     >
224                         <div className={classes.titleWrapper} style={currentRoute?.includes('search-results') || !!progressBar ? {marginBottom: '-20px'} : {}}>
225                             {title && (
226                                 <Grid
227                                     item
228                                     xs
229                                     className={!!progressBar ? classes.subProcessTitle : classes.title}
230                                 >
231                                     {title}
232                                 </Grid>
233                             )}
234                             {!!progressBar && progressBar}
235                             {this.multiSelectToolbarInTitle && <MultiselectToolbar />}
236                             {(!hideColumnSelector || !hideSearchInput || !!actions) && (
237                                 <Grid
238                                     className={classes.headerMenu}
239                                     item
240                                     xs
241                                 >
242                                     <Toolbar className={classes.toolbar}>
243                                         <Grid container justify="space-between" wrap="nowrap" alignItems="center">
244                                             {!hideSearchInput && (
245                                                 <div className={classes.searchBox}>
246                                                     {!hideSearchInput && (
247                                                         <SearchInput
248                                                             label={searchLabel}
249                                                             value={searchValue}
250                                                             selfClearProp={""}
251                                                             onSearch={onSearch}
252                                                         />
253                                                     )}
254                                                 </div>
255                                             )}
256                                             {actions}
257                                             {!hideColumnSelector && (
258                                                 <ColumnSelector
259                                                     columns={columns}
260                                                     onColumnToggle={onColumnToggle}
261                                                 />
262                                             )}
263                                         </Grid>
264                                         {doUnMaximizePanel && panelMaximized && (
265                                             <Tooltip
266                                                 title={`Unmaximize ${panelName || "panel"}`}
267                                                 disableFocusListener
268                                             >
269                                                 <IconButton onClick={doUnMaximizePanel}>
270                                                     <UnMaximizeIcon />
271                                                 </IconButton>
272                                             </Tooltip>
273                                         )}
274                                         {doMaximizePanel && !panelMaximized && (
275                                             <Tooltip
276                                                 title={`Maximize ${panelName || "panel"}`}
277                                                 disableFocusListener
278                                             >
279                                                 <IconButton onClick={doMaximizePanel}>
280                                                     <MaximizeIcon />
281                                                 </IconButton>
282                                             </Tooltip>
283                                         )}
284                                         {doHidePanel && (
285                                             <Tooltip
286                                                 title={`Close ${panelName || "panel"}`}
287                                                 disableFocusListener
288                                             >
289                                                 <IconButton
290                                                     disabled={panelMaximized}
291                                                     onClick={doHidePanel}
292                                                 >
293                                                     <CloseIcon />
294                                                 </IconButton>
295                                             </Tooltip>
296                                         )}
297                                     </Toolbar>
298                                 </Grid>
299                             )}
300                         </div>
301                         {!this.multiSelectToolbarInTitle && <MultiselectToolbar />}
302                         <Grid
303                             item
304                             xs="auto"
305                             className={classes.dataTable}
306                             style={currentRoute?.includes('search-results')  || !!progressBar ? {marginTop: '-10px'} : {}}
307                         >
308                             <DataTable
309                                 columns={this.props.contextMenuColumn ? [...columns, this.contextMenuColumn] : columns}
310                                 items={items}
311                                 onRowClick={(_, item: T) => onRowClick(item)}
312                                 onContextMenu={onContextMenu}
313                                 onRowDoubleClick={(_, item: T) => onRowDoubleClick(item)}
314                                 onFiltersChange={onFiltersChange}
315                                 onSortToggle={onSortToggle}
316                                 extractKey={extractKey}
317                                 working={this.state.showLoading}
318                                 defaultViewIcon={defaultViewIcon}
319                                 defaultViewMessages={defaultViewMessages}
320                                 currentItemUuid={currentItemUuid}
321                                 currentRoute={paperKey}
322                                 toggleMSToolbar={toggleMSToolbar}
323                                 setCheckedListOnStore={setCheckedListOnStore}
324                                 checkedList={checkedList}
325                             />
326                         </Grid>
327                         <Grid
328                             item
329                             xs
330                         >
331                             <Toolbar className={classes.footer}>
332                                 {elementPath && (
333                                     <Grid container>
334                                         <span data-cy="element-path">{elementPath}</span>
335                                     </Grid>
336                                 )}
337                                 <Grid
338                                     container={!elementPath}
339                                     justify="flex-end"
340                                 >
341                                     {fetchMode === DataTableFetchMode.PAGINATED ? (
342                                         <TablePagination
343                                             count={itemsAvailable}
344                                             rowsPerPage={rowsPerPage}
345                                             rowsPerPageOptions={rowsPerPageOptions}
346                                             page={this.props.page}
347                                             onChangePage={this.changePage}
348                                             onChangeRowsPerPage={this.changeRowsPerPage}
349                                             // Disable next button on empty lists since that's not default behavior
350                                             nextIconButtonProps={itemsAvailable > 0 ? {} : { disabled: true }}
351                                             component="div"
352                                         />
353                                     ) : (
354                                         <Button
355                                             variant="text"
356                                             size="medium"
357                                             onClick={this.loadMore}
358                                         >
359                                             Load more
360                                         </Button>
361                                     )}
362                                 </Grid>
363                             </Toolbar>
364                         </Grid>
365                     </Grid>
366                 </Paper>
367             );
368         }
369
370         changePage = (event: React.MouseEvent<HTMLButtonElement>, page: number) => {
371             this.props.onChangePage(page);
372         };
373
374         changeRowsPerPage: React.ChangeEventHandler<HTMLTextAreaElement | HTMLInputElement> = event => {
375             this.props.onChangeRowsPerPage(parseInt(event.target.value, 10));
376         };
377
378         loadMore = () => {
379             this.props.onLoadMore(this.props.page + 1);
380         };
381
382         renderContextMenuTrigger = (item: T) => (
383             <Grid
384                 container
385                 justify="center"
386             >
387                 <Tooltip
388                     title="More options"
389                     disableFocusListener
390                 >
391                     <IconButton
392                         className={this.props.classes.moreOptionsButton}
393                         onClick={event => {
394                             event.stopPropagation()
395                             this.props.onContextMenu(event, item)
396                         }}
397                     >
398                         <MoreVerticalIcon />
399                     </IconButton>
400                 </Tooltip>
401             </Grid>
402         );
403
404         contextMenuColumn: DataColumn<any, any> = {
405             name: "Actions",
406             selected: true,
407             configurable: false,
408             filters: createTree(),
409             key: "context-actions",
410             render: this.renderContextMenuTrigger,
411         };
412     }
413 );