20251: Fix flaky collection file browser by using race-free state update callback
[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 { createTree } from 'models/tree';
13 import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree';
14 import {
15     CloseIcon,
16     IconType,
17     MaximizeIcon,
18     UnMaximizeIcon,
19     MoreOptionsIcon
20 } from 'components/icon/icon';
21 import { PaperProps } from '@material-ui/core/Paper';
22 import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
23
24 type CssRules = 'searchBox' | 'headerMenu' | "toolbar" | "footer" | "root" | 'moreOptionsButton' | 'title' | 'dataTable' | 'container';
25
26 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
27     searchBox: {
28         paddingBottom: 0,
29     },
30     toolbar: {
31         paddingTop: 0,
32         paddingRight: theme.spacing.unit,
33     },
34     footer: {
35         overflow: 'auto'
36     },
37     root: {
38         height: '100%',
39     },
40     moreOptionsButton: {
41         padding: 0
42     },
43     title: {
44         display: 'inline-block',
45         paddingLeft: theme.spacing.unit * 2,
46         paddingTop: theme.spacing.unit * 2,
47         fontSize: '18px'
48     },
49     dataTable: {
50         height: '100%',
51         overflow: 'auto',
52     },
53     container: {
54         height: '100%',
55     },
56     headerMenu: {
57         float: 'right',
58         display: 'inline-block',
59     }
60 });
61
62 interface DataExplorerDataProps<T> {
63     fetchMode: DataTableFetchMode;
64     items: T[];
65     itemsAvailable: number;
66     columns: DataColumns<T, any>;
67     searchLabel?: string;
68     searchValue: string;
69     rowsPerPage: number;
70     rowsPerPageOptions: number[];
71     page: number;
72     contextMenuColumn: boolean;
73     defaultViewIcon?: IconType;
74     defaultViewMessages?: string[];
75     working?: boolean;
76     currentRefresh?: string;
77     currentRoute?: string;
78     hideColumnSelector?: boolean;
79     paperProps?: PaperProps;
80     actions?: React.ReactNode;
81     hideSearchInput?: boolean;
82     title?: React.ReactNode;
83     paperKey?: string;
84     currentItemUuid: string;
85     elementPath?: string;
86 }
87
88 interface DataExplorerActionProps<T> {
89     onSetColumns: (columns: DataColumns<T, any>) => void;
90     onSearch: (value: string) => void;
91     onRowClick: (item: T) => void;
92     onRowDoubleClick: (item: T) => void;
93     onColumnToggle: (column: DataColumn<T, any>) => void;
94     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: T) => void;
95     onSortToggle: (column: DataColumn<T, any>) => void;
96     onFiltersChange: (filters: DataTableFilters, column: DataColumn<T, any>) => void;
97     onChangePage: (page: number) => void;
98     onChangeRowsPerPage: (rowsPerPage: number) => void;
99     onLoadMore: (page: number) => void;
100     extractKey?: (item: T) => React.Key;
101 }
102
103 type DataExplorerProps<T> = DataExplorerDataProps<T> &
104     DataExplorerActionProps<T> & WithStyles<CssRules> & MPVPanelProps;
105
106 export const DataExplorer = withStyles(styles)(
107     class DataExplorerGeneric<T> extends React.Component<DataExplorerProps<T>> {
108         state = {
109             showLoading: false,
110             prevRefresh: '',
111             prevRoute: '',
112         };
113
114         componentDidUpdate(prevProps: DataExplorerProps<T>) {
115             const currentRefresh = this.props.currentRefresh || '';
116             const currentRoute = this.props.currentRoute || '';
117
118             if (currentRoute !== this.state.prevRoute) {
119                 // Component already mounted, but the user comes from a route change,
120                 // like browsing through a project hierarchy.
121                 this.setState({
122                     showLoading: this.props.working,
123                     prevRoute: currentRoute,
124                 });
125             }
126
127             if (currentRefresh !== this.state.prevRefresh) {
128                 // Component already mounted, but the user just clicked the
129                 // refresh button.
130                 this.setState({
131                     showLoading: this.props.working,
132                     prevRefresh: currentRefresh,
133                 });
134             }
135             if (this.state.showLoading && !this.props.working) {
136                 this.setState({
137                     showLoading: false,
138                 });
139             }
140         }
141
142         componentDidMount() {
143             if (this.props.onSetColumns) {
144                 this.props.onSetColumns(this.props.columns);
145             }
146             // Component just mounted, so we need to show the loading indicator.
147             this.setState({
148                 showLoading: this.props.working,
149                 prevRefresh: this.props.currentRefresh || '',
150                 prevRoute: this.props.currentRoute || '',
151             });
152         }
153
154         render() {
155             const {
156                 columns, onContextMenu, onFiltersChange, onSortToggle, extractKey,
157                 rowsPerPage, rowsPerPageOptions, onColumnToggle, searchLabel, searchValue, onSearch,
158                 items, itemsAvailable, onRowClick, onRowDoubleClick, classes,
159                 defaultViewIcon, defaultViewMessages, hideColumnSelector, actions, paperProps, hideSearchInput,
160                 paperKey, fetchMode, currentItemUuid, title,
161                 doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName, panelMaximized, elementPath
162             } = this.props;
163             return <Paper className={classes.root} {...paperProps} key={paperKey} data-cy={this.props["data-cy"]}>
164                 <Grid container direction="column" wrap="nowrap" className={classes.container}>
165                     <div>
166                         {title && <Grid item xs className={classes.title}>{title}</Grid>}
167                         {
168                             (!hideColumnSelector || !hideSearchInput || !!actions) &&
169                             <Grid className={classes.headerMenu} item xs>
170                                 <Toolbar className={classes.toolbar}>
171                                     {!hideSearchInput && <div className={classes.searchBox}>
172                                         {!hideSearchInput && <SearchInput
173                                             label={searchLabel}
174                                             value={searchValue}
175                                             selfClearProp={''}
176                                             onSearch={onSearch} />}
177                                     </div>}
178                                     {actions}
179                                     {!hideColumnSelector && <ColumnSelector
180                                         columns={columns}
181                                         onColumnToggle={onColumnToggle} />}
182                                     { doUnMaximizePanel && panelMaximized &&
183                                     <Tooltip title={`Unmaximize ${panelName || 'panel'}`} disableFocusListener>
184                                         <IconButton onClick={doUnMaximizePanel}><UnMaximizeIcon /></IconButton>
185                                     </Tooltip> }
186                                     { doMaximizePanel && !panelMaximized &&
187                                         <Tooltip title={`Maximize ${panelName || 'panel'}`} disableFocusListener>
188                                             <IconButton onClick={doMaximizePanel}><MaximizeIcon /></IconButton>
189                                         </Tooltip> }
190                                     { doHidePanel &&
191                                         <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
192                                             <IconButton disabled={panelMaximized} onClick={doHidePanel}><CloseIcon /></IconButton>
193                                         </Tooltip> }
194                                 </Toolbar>
195                             </Grid>
196                         }
197                     </div>
198                 <Grid item xs="auto" className={classes.dataTable}><DataTable
199                     columns={this.props.contextMenuColumn ? [...columns, this.contextMenuColumn] : columns}
200                     items={items}
201                     onRowClick={(_, item: T) => onRowClick(item)}
202                     onContextMenu={onContextMenu}
203                     onRowDoubleClick={(_, item: T) => onRowDoubleClick(item)}
204                     onFiltersChange={onFiltersChange}
205                     onSortToggle={onSortToggle}
206                     extractKey={extractKey}
207                     working={this.state.showLoading}
208                     defaultViewIcon={defaultViewIcon}
209                     defaultViewMessages={defaultViewMessages}
210                     currentItemUuid={currentItemUuid}
211                     currentRoute={paperKey} /></Grid>
212                 <Grid item xs><Toolbar className={classes.footer}>
213                     {
214                         elementPath &&
215                         <Grid container>
216                             <span data-cy="element-path">
217                                 {elementPath}
218                             </span>
219                         </Grid>
220                     }
221                     <Grid container={!elementPath} justify="flex-end">
222                         {fetchMode === DataTableFetchMode.PAGINATED ? <TablePagination
223                             count={itemsAvailable}
224                             rowsPerPage={rowsPerPage}
225                             rowsPerPageOptions={rowsPerPageOptions}
226                             page={this.props.page}
227                             onChangePage={this.changePage}
228                             onChangeRowsPerPage={this.changeRowsPerPage}
229                             // Disable next button on empty lists since that's not default behavior
230                             nextIconButtonProps={(itemsAvailable > 0) ? {} : {disabled: true}}
231                             component="div" /> : <Button
232                                 variant="text"
233                                 size="medium"
234                                 onClick={this.loadMore}
235                             >Load more</Button>}
236                     </Grid>
237                 </Toolbar></Grid>
238                 </Grid>
239             </Paper>;
240         }
241
242         changePage = (event: React.MouseEvent<HTMLButtonElement>, page: number) => {
243             this.props.onChangePage(page);
244         }
245
246         changeRowsPerPage: React.ChangeEventHandler<HTMLTextAreaElement | HTMLInputElement> = (event) => {
247             this.props.onChangeRowsPerPage(parseInt(event.target.value, 10));
248         }
249
250         loadMore = () => {
251             this.props.onLoadMore(this.props.page + 1);
252         }
253
254         renderContextMenuTrigger = (item: T) =>
255             <Grid container justify="center">
256                 <Tooltip title="More options" disableFocusListener>
257                     <IconButton className={this.props.classes.moreOptionsButton} onClick={event => this.props.onContextMenu(event, item)}>
258                         <MoreOptionsIcon />
259                     </IconButton>
260                 </Tooltip>
261             </Grid>
262
263         contextMenuColumn: DataColumn<any, any> = {
264             name: "Actions",
265             selected: true,
266             configurable: false,
267             filters: createTree(),
268             key: "context-actions",
269             render: this.renderContextMenuTrigger
270         };
271     }
272 );