22159: changed renderBodyRow to accept T instead of any
[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, CustomTheme, ArvadosTheme } 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 { isEqual } from "lodash";
21 import { DataColumn, DataColumns, SortDirection } from "./data-column";
22 import { DataTableDefaultView } from "../data-table-default-view/data-table-default-view";
23 import { DataTableFilters } from "../data-table-filters/data-table-filters";
24 import { DataTableMultiselectPopover, DataTableMultiselectOption } from "components/data-table-multiselect-popover/data-table-multiselect-popover";
25 import { DataTableFiltersPopover } from "../data-table-filters/data-table-filters-popover";
26 import { countNodes, getTreeDirty, createTree } from "models/tree";
27 import { IconType, PendingIcon } from "components/icon/icon";
28 import { SvgIconProps } from "@mui/material/SvgIcon";
29 import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
30 import { isExactlyOneSelected } from "store/multiselect/multiselect-actions";
31
32 export enum DataTableFetchMode {
33     PAGINATED,
34     INFINITE,
35 }
36
37 export interface DataTableDataProps<T> {
38     items: T[];
39     columns: DataColumns<any>;
40     onRowClick: (event: React.MouseEvent<HTMLTableRowElement>, item: T) => void;
41     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: T) => void;
42     onRowDoubleClick: (event: React.MouseEvent<HTMLTableRowElement>, item: T) => void;
43     onSortToggle: (column: DataColumn<T>) => void;
44     onFiltersChange: (filters: DataTableFilters, column: DataColumn<T>) => void;
45     extractKey?: (item: T) => React.Key;
46     working?: boolean;
47     defaultViewIcon?: IconType;
48     defaultViewMessages?: string[];
49     toggleMSToolbar: (isVisible: boolean) => void;
50     setCheckedListOnStore: (checkedList: TCheckedList) => void;
51     currentRoute?: string;
52     currentRouteUuid: string;
53     checkedList: TCheckedList;
54     selectedResourceUuid: string;
55     setSelectedUuid: (uuid: string | null) => void;
56     isNotFound?: boolean;
57     detailsPanelResourceUuid?: string;
58     loadDetailsPanel: (uuid: string) => void;
59 }
60
61 type CssRules =
62     | "tableBody"
63     | "root"
64     | "content"
65     | "noItemsInfo"
66     | "checkBoxHead"
67     | "checkBoxCell"
68     | "clickBox"
69     | "checkBox"
70     | "firstTableCell"
71     | "tableCell"
72     | "firstTableHead"
73     | "tableHead"
74     | "selected"
75     | "hovered"
76     | "arrow"
77     | "arrowButton"
78     | "tableCellWorkflows";
79
80 const styles: CustomStyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
81     root: {
82         width: "100%",
83     },
84     content: {
85         display: "inline-block",
86         width: "100%",
87     },
88     tableBody: {
89         background: theme.palette.background.paper,
90         overflow: "auto",
91     },
92     noItemsInfo: {
93         textAlign: "center",
94         padding: theme.spacing(1),
95     },
96     checkBoxHead: {
97         padding: "0",
98         display: "flex",
99         width: '2rem',
100         height: "1.5rem",
101         paddingLeft: '0.9rem',
102         marginRight: '0.5rem',
103         backgroundColor: theme.palette.background.paper,
104     },
105     checkBoxCell: {
106         padding: "0",
107         backgroundColor: theme.palette.background.paper,
108     },
109     clickBox: {
110         display: 'flex',
111         width: '1.6rem',
112         height: "1.5rem",
113         paddingLeft: '0.35rem',
114         paddingTop: '0.1rem',
115         marginLeft: '0.5rem',
116         cursor: "pointer",
117     },
118     checkBox: {
119         cursor: "pointer",
120     },
121     tableCell: {
122         wordWrap: "break-word",
123         paddingRight: "24px",
124     },
125     firstTableCell: {
126         paddingLeft: "5px",
127     },
128     firstTableHead: {
129         paddingLeft: "5px",
130     },
131     tableHead: {
132         wordWrap: "break-word",
133         paddingRight: "24px",
134         color: "#737373",
135         fontSize: "0.8125rem",
136         backgroundColor: theme.palette.background.paper,
137     },
138     selected: {
139         backgroundColor: `${CustomTheme.palette.grey['300']} !important`
140     },
141     hovered: {
142         backgroundColor: `${CustomTheme.palette.grey['100']} !important`
143     },
144     tableCellWorkflows: {
145         "&:nth-last-child(2)": {
146             padding: "0px",
147             maxWidth: "48px",
148         },
149         "&:last-child": {
150             padding: "0px",
151             paddingRight: "24px",
152             width: "48px",
153         },
154     },
155     arrow: {
156         margin: 0,
157     },
158     arrowButton: {
159         color: theme.palette.text.primary,
160     },
161 });
162
163 export type TCheckedList = Record<string, boolean>;
164
165 type DataTableState = {
166     isSelected: boolean;
167     isLoaded: boolean;
168     hoveredIndex: number | null;
169 };
170
171 type DataTableProps<T> = DataTableDataProps<T> & WithStyles<CssRules>;
172
173 // tell compiler that all items will have a uuid prop
174 export interface DataTableItem {
175     uuid: string;
176 }
177
178 export const DataTable = withStyles(styles)(
179     class Component<T extends DataTableItem> 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             if(this.props.detailsPanelResourceUuid !== this.props.selectedResourceUuid) {
192                 this.props.loadDetailsPanel(this.props.selectedResourceUuid);
193             }
194         }
195
196         componentDidUpdate(prevProps: Readonly<DataTableProps<T>>, prevState: DataTableState) {
197             const { items, currentRouteUuid, setCheckedListOnStore } = this.props;
198             const { isSelected } = this.state;
199             const singleSelected = isExactlyOneSelected(this.props.checkedList);
200             if (!isEqual(prevProps.items, items)) {
201                 if (isSelected === true) this.setState({ isSelected: false });
202                 if (items.length) this.initializeCheckedList(items.map((item: any) => item.uuid));
203                 else setCheckedListOnStore({});
204             }
205             if (prevProps.currentRoute !== this.props.currentRoute) {
206                 this.initializeCheckedList([]);
207             }
208             if (singleSelected && singleSelected !== isExactlyOneSelected(prevProps.checkedList)) {
209                 this.props.setSelectedUuid(singleSelected);
210             }
211             if (!singleSelected && !!currentRouteUuid && !this.isAnySelected()) {
212                 this.props.setSelectedUuid(currentRouteUuid);
213             }
214             if (!singleSelected && this.isAnySelected()) {
215                 this.props.setSelectedUuid(null);
216             }
217             if(prevProps.working === false && this.props.working === true) {
218                 this.setState({ isLoaded: false });
219             }
220             if(prevProps.working === true && this.props.working === false) {
221                 this.setState({ isLoaded: true });
222             }
223             if((this.props.items.length > 0) && !this.state.isLoaded) {
224                 this.setState({ isLoaded: true });
225             }
226         }
227
228         componentWillUnmount(): void {
229             this.initializeCheckedList([]);
230         }
231
232         checkBoxColumn: DataColumn<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>) => {
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                         data-cy="data-table-default-view"
391                         icon={this.props.defaultViewIcon}
392                         messages={this.props.defaultViewMessages}
393                         filtersApplied={dirty}
394                     />
395                 );
396             }
397         };
398
399         renderHeadCell = (column: DataColumn<T>, index: number) => {
400             const { name, key, renderHeader, filters, sort } = column;
401             const { onSortToggle, onFiltersChange, classes, checkedList } = this.props;
402             const { isSelected } = this.state;
403             return column.name === "checkBoxColumn" ? (
404                 <TableCell
405                     key={key || index}
406                     className={classes.checkBoxCell}>
407                     <div className={classes.checkBoxHead}>
408                         <Tooltip title={this.state.isSelected ? "Deselect all" : "Select all"}>
409                             <input
410                                 type="checkbox"
411                                 className={classes.checkBox}
412                                 checked={isSelected}
413                                 disabled={!this.props.items.length}
414                                 onChange={this.handleSelectorSelect}></input>
415                         </Tooltip>
416                         <DataTableMultiselectPopover
417                             name={`Options`}
418                             disabled={!this.props.items.length}
419                             options={this.multiselectOptions}
420                             checkedList={checkedList}></DataTableMultiselectPopover>
421                     </div>
422                 </TableCell>
423             ) : (
424                 <TableCell
425                     className={classnames(classes.tableHead, index === 1 ? classes.firstTableHead : '')}
426                     key={key || index}>
427                     {renderHeader ? (
428                         renderHeader()
429                     ) : countNodes(filters) > 0 ? (
430                         <DataTableFiltersPopover
431                             name={`${name} filters`}
432                             mutuallyExclusive={column.mutuallyExclusiveFilters}
433                             onChange={filters => onFiltersChange && onFiltersChange(filters, column)}
434                             filters={filters}>
435                             {name}
436                         </DataTableFiltersPopover>
437                     ) : sort ? (
438                         <TableSortLabel
439                             active={sort.direction !== SortDirection.NONE}
440                             direction={sort.direction !== SortDirection.NONE ? sort.direction : undefined}
441                             IconComponent={this.ArrowIcon}
442                             hideSortIcon
443                             onClick={() => onSortToggle && onSortToggle(column)}>
444                             {name}
445                         </TableSortLabel>
446                     ) : (
447                         <span>{name}</span>
448                     )}
449                 </TableCell>
450             );
451         };
452
453         ArrowIcon = ({ className, ...props }: SvgIconProps) => (
454             <IconButton
455                 data-cy="sort-button"
456                 component="span"
457                 className={this.props.classes.arrowButton}
458                 tabIndex={-1}
459                 size="large">
460                 <ArrowDownwardIcon
461                     {...props}
462                     className={classnames(className, this.props.classes.arrow)}
463                 />
464             </IconButton>
465         );
466
467         renderBodyRow = (item: T, index: number) => {
468             const { onRowClick, onRowDoubleClick, extractKey, classes, selectedResourceUuid, currentRoute } = this.props;
469             const { hoveredIndex } = this.state;
470             const isRowSelected = item.uuid === selectedResourceUuid;
471             const getClassnames = (colIndex: number) => {
472                 if(currentRoute === '/workflows') return classes.tableCellWorkflows;
473                 if(colIndex === 0) return classnames(classes.checkBoxCell, isRowSelected ? classes.selected : index === hoveredIndex ? classes.hovered : "");
474                 if(colIndex === 1) return classnames(classes.tableCell, classes.firstTableCell, isRowSelected ? classes.selected : "");
475                 return classnames(classes.tableCell, isRowSelected ? classes.selected : "");
476             };
477             const handleHover = (index: number | null) => {
478                 this.setState({ hoveredIndex: index });
479             }
480
481             return (
482                 <TableRow
483                     data-cy={'data-table-row'}
484                     hover
485                     key={extractKey ? extractKey(item) : index}
486                     onClick={event => onRowClick && onRowClick(event, item)}
487                     onContextMenu={this.handleRowContextMenu(item)}
488                     onDoubleClick={event => onRowDoubleClick && onRowDoubleClick(event, item)}
489                     selected={isRowSelected}
490                     className={isRowSelected ? classes.selected : ""}
491                     onMouseEnter={()=>handleHover(index)}
492                     onMouseLeave={()=>handleHover(null)}
493                 >
494                     {this.mapVisibleColumns((column, colIndex) => (
495                         <TableCell
496                             key={column.key || colIndex}
497                             data-cy={column.key || colIndex}
498                             className={getClassnames(colIndex)}>
499                             {column.render(item)}
500                         </TableCell>
501                     ))}
502                 </TableRow>
503             );
504         };
505
506         mapVisibleColumns = (fn: (column: DataColumn<T>, index: number) => React.ReactElement<any>) => {
507             return this.props.columns.filter(column => column.selected).map(fn);
508         };
509
510         handleRowContextMenu = (item: T) => (event: React.MouseEvent<HTMLElement>) => this.props.onContextMenu(event, item);
511     }
512 );