22135: adjusted componentDidMount/Update to better address isLoaded state
[arvados.git] / services / workbench2 / src / components / data-table / data-table.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     Table,
9     TableBody,
10     TableRow,
11     TableCell,
12     TableHead,
13     TableSortLabel,
14     IconButton,
15     Tooltip,
16 } from "@mui/material";
17 import { WithStyles } from '@mui/styles';
18 import withStyles from '@mui/styles/withStyles';
19 import classnames from "classnames";
20 import { DataColumn, SortDirection } from "./data-column";
21 import { DataTableDefaultView } from "../data-table-default-view/data-table-default-view";
22 import { DataTableFilters } from "../data-table-filters/data-table-filters-tree";
23 import { DataTableMultiselectPopover } from "../data-table-multiselect-popover/data-table-multiselect-popover";
24 import { DataTableFiltersPopover } from "../data-table-filters/data-table-filters-popover";
25 import { countNodes, getTreeDirty } from "models/tree";
26 import { IconType } from "components/icon/icon";
27 import { SvgIconProps } from "@mui/material/SvgIcon";
28 import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
29 import { createTree } from "models/tree";
30 import { DataTableMultiselectOption } from "../data-table-multiselect-popover/data-table-multiselect-popover";
31 import { isExactlyOneSelected } from "store/multiselect/multiselect-actions";
32 import { PendingIcon } from "components/icon/icon";
33 import { CustomTheme, ArvadosTheme } from "common/custom-theme";
34
35 export type DataColumns<I, R> = Array<DataColumn<I, R>>;
36
37 export enum DataTableFetchMode {
38     PAGINATED,
39     INFINITE,
40 }
41
42 export interface DataTableDataProps<I> {
43     items: I[];
44     columns: DataColumns<I, any>;
45     onRowClick: (event: React.MouseEvent<HTMLTableRowElement>, item: I) => void;
46     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: I) => void;
47     onRowDoubleClick: (event: React.MouseEvent<HTMLTableRowElement>, item: I) => void;
48     onSortToggle: (column: DataColumn<I, any>) => void;
49     onFiltersChange: (filters: DataTableFilters, column: DataColumn<I, any>) => void;
50     extractKey?: (item: I) => React.Key;
51     working?: boolean;
52     defaultViewIcon?: IconType;
53     defaultViewMessages?: string[];
54     toggleMSToolbar: (isVisible: boolean) => void;
55     setCheckedListOnStore: (checkedList: TCheckedList) => void;
56     currentRoute?: string;
57     currentRouteUuid: string;
58     checkedList: TCheckedList;
59     selectedResourceUuid: string;
60     setSelectedUuid: (uuid: string | null) => void;
61     isNotFound?: boolean;
62     detailsPanelResourceUuid?: string;
63     loadDetailsPanel: (uuid: string) => void;
64 }
65
66 type CssRules =
67     | "tableBody"
68     | "root"
69     | "content"
70     | "noItemsInfo"
71     | "checkBoxHead"
72     | "checkBoxCell"
73     | "clickBox"
74     | "checkBox"
75     | "firstTableCell"
76     | "tableCell"
77     | "firstTableHead"
78     | "tableHead"
79     | "selected"
80     | "hovered"
81     | "arrow"
82     | "arrowButton"
83     | "tableCellWorkflows";
84
85 const styles: CustomStyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
86     root: {
87         width: "100%",
88     },
89     content: {
90         display: "inline-block",
91         width: "100%",
92     },
93     tableBody: {
94         background: theme.palette.background.paper,
95         overflow: "auto",
96     },
97     noItemsInfo: {
98         textAlign: "center",
99         padding: theme.spacing(1),
100     },
101     checkBoxHead: {
102         padding: "0",
103         display: "flex",
104         width: '2rem',
105         height: "1.5rem",
106         paddingLeft: '0.9rem',
107         marginRight: '0.5rem',
108         backgroundColor: theme.palette.background.paper,
109     },
110     checkBoxCell: {
111         padding: "0",
112         backgroundColor: theme.palette.background.paper,
113     },
114     clickBox: {
115         display: 'flex',
116         width: '1.6rem',
117         height: "1.5rem",
118         paddingLeft: '0.35rem',
119         paddingTop: '0.1rem',
120         marginLeft: '0.5rem',
121         cursor: "pointer",
122     },
123     checkBox: {
124         cursor: "pointer",
125     },
126     tableCell: {
127         wordWrap: "break-word",
128         paddingRight: "24px",
129     },
130     firstTableCell: {
131         paddingLeft: "5px",
132     },
133     firstTableHead: {
134         paddingLeft: "5px",
135     },
136     tableHead: {
137         wordWrap: "break-word",
138         paddingRight: "24px",
139         color: "#737373",
140         fontSize: "0.8125rem",
141         backgroundColor: theme.palette.background.paper,
142     },
143     selected: {
144         backgroundColor: `${CustomTheme.palette.grey['300']} !important`
145     },
146     hovered: {
147         backgroundColor: `${CustomTheme.palette.grey['100']} !important`
148     },
149     tableCellWorkflows: {
150         "&:nth-last-child(2)": {
151             padding: "0px",
152             maxWidth: "48px",
153         },
154         "&:last-child": {
155             padding: "0px",
156             paddingRight: "24px",
157             width: "48px",
158         },
159     },
160     arrow: {
161         margin: 0,
162     },
163     arrowButton: {
164         color: theme.palette.text.primary,
165     },
166 });
167
168 export type TCheckedList = Record<string, boolean>;
169
170 type DataTableState = {
171     isSelected: boolean;
172     isLoaded: boolean;
173     hoveredIndex: number | null;
174 };
175
176 type DataTableProps<T> = DataTableDataProps<T> & WithStyles<CssRules>;
177
178 export const DataTable = withStyles(styles)(
179     class Component<T> extends React.Component<DataTableProps<T>> {
180         state: DataTableState = {
181             isSelected: false,
182             isLoaded: false,
183             hoveredIndex: null,
184         };
185
186         componentDidMount(): void {
187             this.initializeCheckedList([]);
188             if((this.props.items.length > 0) && !this.state.isLoaded || !this.props.working) {
189                 this.setState({ isLoaded: true });
190             }
191         }
192
193         componentDidUpdate(prevProps: Readonly<DataTableProps<T>>, prevState: DataTableState) {
194             const { items, currentRouteUuid, setCheckedListOnStore } = this.props;
195             const { isSelected } = this.state;
196             const singleSelected = isExactlyOneSelected(this.props.checkedList);
197             if (prevProps.items !== items) {
198                 if (isSelected === true) this.setState({ isSelected: false });
199                 if (items.length) this.initializeCheckedList(items);
200                 else setCheckedListOnStore({});
201             }
202             if (prevProps.currentRoute !== this.props.currentRoute) {
203                 this.initializeCheckedList([]);
204             }
205             if (singleSelected && singleSelected !== isExactlyOneSelected(prevProps.checkedList)) {
206                 this.props.setSelectedUuid(singleSelected);
207             }
208             if (!singleSelected && !!currentRouteUuid && !this.isAnySelected()) {
209                 this.props.setSelectedUuid(currentRouteUuid);
210             }
211             if (!singleSelected && this.isAnySelected()) {
212                 this.props.setSelectedUuid(null);
213             }
214             if(prevProps.working === false && this.props.working === true) {
215                 this.setState({ isLoaded: false });
216             }
217             if(prevProps.working === true && this.props.working === false) {
218                 this.setState({ isLoaded: true });
219             }
220             if((this.props.items.length > 0) && !this.state.isLoaded) {
221                 this.setState({ isLoaded: true });
222             }
223             if(this.props.detailsPanelResourceUuid !== this.props.selectedResourceUuid) {
224                 this.props.loadDetailsPanel(this.props.selectedResourceUuid);
225             }
226         }
227
228         componentWillUnmount(): void {
229             this.initializeCheckedList([]);
230         }
231
232         checkBoxColumn: DataColumn<any, any> = {
233             name: "checkBoxColumn",
234             selected: true,
235             configurable: false,
236             filters: createTree(),
237             render: uuid => {
238                 const { classes, checkedList } = this.props;
239                 return (
240                     <div
241                         className={classes.clickBox}
242                         onClick={(ev) => {
243                             ev.stopPropagation()
244                             this.handleSelectOne(uuid)
245                         }}
246                         onDoubleClick={(ev) => ev.stopPropagation()}
247                     >
248                         <input
249                             data-cy={`multiselect-checkbox-${uuid}`}
250                             type='checkbox'
251                             name={uuid}
252                             className={classes.checkBox}
253                             checked={checkedList && checkedList[uuid] ? checkedList[uuid] : false}
254                             onChange={() => this.handleSelectOne(uuid)}
255                             onDoubleClick={(ev) => ev.stopPropagation()}
256                         ></input>
257                     </div>
258                 );
259             },
260         };
261
262         multiselectOptions: DataTableMultiselectOption[] = [
263             { name: "All", fn: list => this.handleSelectAll(list) },
264             { name: "None", fn: list => this.handleSelectNone(list) },
265             { name: "Invert", fn: list => this.handleInvertSelect(list) },
266         ];
267
268         initializeCheckedList = (uuids: any[]): void => {
269             const newCheckedList = { ...this.props.checkedList };
270
271             if(Object.keys(newCheckedList).length === 0){
272                 for(const uuid of uuids){
273                     newCheckedList[uuid] = false
274                 }
275             }
276
277             for (const key in newCheckedList) {
278                 if (!uuids.includes(key)) {
279                     delete newCheckedList[key];
280                 }
281             }
282             this.props.setCheckedListOnStore(newCheckedList);
283         };
284
285         isAllSelected = (list: TCheckedList): boolean => {
286             for (const key in list) {
287                 if (list[key] === false) return false;
288             }
289             return true;
290         };
291
292         isAnySelected = (): boolean => {
293             const { checkedList } = this.props;
294             if (!checkedList) return false;
295             if (!Object.keys(checkedList).length) return false;
296             for (const key in checkedList) {
297                 if (checkedList[key] === true) return true;
298             }
299             return false;
300         };
301
302         handleSelectOne = (uuid: string): void => {
303             const { checkedList } = this.props;
304             const newCheckedList = { ...checkedList };
305             newCheckedList[uuid] = !checkedList[uuid];
306             this.setState({ isSelected: this.isAllSelected(newCheckedList) });
307             this.props.setCheckedListOnStore(newCheckedList);
308         };
309
310         handleSelectorSelect = (): void => {
311             const { checkedList } = this.props;
312             const { isSelected } = this.state;
313             isSelected ? this.handleSelectNone(checkedList) : this.handleSelectAll(checkedList);
314         };
315
316         handleSelectAll = (list: TCheckedList): void => {
317             if (Object.keys(list).length) {
318                 const newCheckedList = { ...list };
319                 for (const key in newCheckedList) {
320                     newCheckedList[key] = true;
321                 }
322                 this.setState({ isSelected: true });
323                 this.props.setCheckedListOnStore(newCheckedList);
324             }
325         };
326
327         handleSelectNone = (list: TCheckedList): void => {
328             const newCheckedList = { ...list };
329             for (const key in newCheckedList) {
330                 newCheckedList[key] = false;
331             }
332             this.setState({ isSelected: false });
333             this.props.setCheckedListOnStore(newCheckedList);
334         };
335
336         handleInvertSelect = (list: TCheckedList): void => {
337             if (Object.keys(list).length) {
338                 const newCheckedList = { ...list };
339                 for (const key in newCheckedList) {
340                     newCheckedList[key] = !list[key];
341                 }
342                 this.setState({ isSelected: this.isAllSelected(newCheckedList) });
343                 this.props.setCheckedListOnStore(newCheckedList);
344             }
345         };
346
347         render() {
348             const { items, classes, columns, isNotFound } = this.props;
349             const { isLoaded } = this.state;
350             if (columns.length && columns[0].name === this.checkBoxColumn.name) columns.shift();
351             columns.unshift(this.checkBoxColumn);
352             return (
353                 <div className={classes.root}>
354                     <div className={classes.content}>
355                         <Table data-cy="data-table" stickyHeader>
356                             <TableHead>
357                                 <TableRow>{this.mapVisibleColumns(this.renderHeadCell)}</TableRow>
358                             </TableHead>
359                             <TableBody className={classes.tableBody}>{(isLoaded && !isNotFound) && items.map(this.renderBodyRow)}</TableBody>
360                         </Table>
361                         {(!isLoaded || isNotFound || items.length === 0) && this.renderNoItemsPlaceholder(this.props.columns)}
362                     </div>
363                 </div>
364             );
365         }
366
367         renderNoItemsPlaceholder = (columns: DataColumns<T, any>) => {
368             const { isLoaded } = this.state;
369             const { working, isNotFound } = this.props;
370             const dirty = columns.some(column => getTreeDirty("")(column.filters));
371             if (isNotFound && isLoaded) {
372                 return (
373                     <DataTableDefaultView
374                         icon={this.props.defaultViewIcon}
375                         messages={["No items found"]}
376                     />
377                 );
378             } else
379             if (isLoaded === false || working === true) {
380                 return (
381                     <DataTableDefaultView
382                         icon={PendingIcon}
383                         messages={["Loading data, please wait"]}
384                     />
385                 );
386             } else {
387                 // isLoaded && !working && !isNotFound
388                 return (
389                     <DataTableDefaultView
390                         icon={this.props.defaultViewIcon}
391                         messages={this.props.defaultViewMessages}
392                         filtersApplied={dirty}
393                     />
394                 );
395             }
396         };
397
398         renderHeadCell = (column: DataColumn<T, any>, index: number) => {
399             const { name, key, renderHeader, filters, sort } = column;
400             const { onSortToggle, onFiltersChange, classes, checkedList } = this.props;
401             const { isSelected } = this.state;
402             return column.name === "checkBoxColumn" ? (
403                 <TableCell
404                     key={key || index}
405                     className={classes.checkBoxCell}>
406                     <div className={classes.checkBoxHead}>
407                         <Tooltip title={this.state.isSelected ? "Deselect all" : "Select all"}>
408                             <input
409                                 type="checkbox"
410                                 className={classes.checkBox}
411                                 checked={isSelected}
412                                 disabled={!this.props.items.length}
413                                 onChange={this.handleSelectorSelect}></input>
414                         </Tooltip>
415                         <DataTableMultiselectPopover
416                             name={`Options`}
417                             disabled={!this.props.items.length}
418                             options={this.multiselectOptions}
419                             checkedList={checkedList}></DataTableMultiselectPopover>
420                     </div>
421                 </TableCell>
422             ) : (
423                 <TableCell
424                     className={classnames(classes.tableHead, index === 1 ? classes.firstTableHead : '')}
425                     key={key || index}>
426                     {renderHeader ? (
427                         renderHeader()
428                     ) : countNodes(filters) > 0 ? (
429                         <DataTableFiltersPopover
430                             name={`${name} filters`}
431                             mutuallyExclusive={column.mutuallyExclusiveFilters}
432                             onChange={filters => onFiltersChange && onFiltersChange(filters, column)}
433                             filters={filters}>
434                             {name}
435                         </DataTableFiltersPopover>
436                     ) : sort ? (
437                         <TableSortLabel
438                             active={sort.direction !== SortDirection.NONE}
439                             direction={sort.direction !== SortDirection.NONE ? sort.direction : undefined}
440                             IconComponent={this.ArrowIcon}
441                             hideSortIcon
442                             onClick={() => onSortToggle && onSortToggle(column)}>
443                             {name}
444                         </TableSortLabel>
445                     ) : (
446                         <span>{name}</span>
447                     )}
448                 </TableCell>
449             );
450         };
451
452         ArrowIcon = ({ className, ...props }: SvgIconProps) => (
453             <IconButton
454                 data-cy="sort-button"
455                 component="span"
456                 className={this.props.classes.arrowButton}
457                 tabIndex={-1}
458                 size="large">
459                 <ArrowDownwardIcon
460                     {...props}
461                     className={classnames(className, this.props.classes.arrow)}
462                 />
463             </IconButton>
464         );
465
466         renderBodyRow = (item: any, index: number) => {
467             const { onRowClick, onRowDoubleClick, extractKey, classes, selectedResourceUuid, currentRoute } = this.props;
468             const { hoveredIndex } = this.state;
469             const isRowSelected = item === selectedResourceUuid;
470             const getClassnames = (colIndex: number) => {
471                 if(currentRoute === '/workflows') return classes.tableCellWorkflows;
472                 if(colIndex === 0) return classnames(classes.checkBoxCell, isRowSelected ? classes.selected : index === hoveredIndex ? classes.hovered : "");
473                 if(colIndex === 1) return classnames(classes.tableCell, classes.firstTableCell, isRowSelected ? classes.selected : "");
474                 return classnames(classes.tableCell, isRowSelected ? classes.selected : "");
475             };
476             const handleHover = (index: number | null) => {
477                 this.setState({ hoveredIndex: index });
478             }
479
480             return (
481                 <TableRow
482                     data-cy={'data-table-row'}
483                     hover
484                     key={extractKey ? extractKey(item) : index}
485                     onClick={event => onRowClick && onRowClick(event, item)}
486                     onContextMenu={this.handleRowContextMenu(item)}
487                     onDoubleClick={event => onRowDoubleClick && onRowDoubleClick(event, item)}
488                     selected={isRowSelected}
489                     className={isRowSelected ? classes.selected : ""}
490                     onMouseEnter={()=>handleHover(index)}
491                     onMouseLeave={()=>handleHover(null)}
492                 >
493                     {this.mapVisibleColumns((column, colIndex) => (
494                         <TableCell
495                             key={column.key || colIndex}
496                             data-cy={column.key || colIndex}
497                             className={getClassnames(colIndex)}>
498                             {column.render(item)}
499                         </TableCell>
500                     ))}
501                 </TableRow>
502             );
503         };
504
505         mapVisibleColumns = (fn: (column: DataColumn<T, any>, index: number) => React.ReactElement<any>) => {
506             return this.props.columns.filter(column => column.selected).map(fn);
507         };
508
509         handleRowContextMenu = (item: T) => (event: React.MouseEvent<HTMLElement>) => this.props.onContextMenu(event, item);
510     }
511 );