Merge branch '22134-railsapi-envvars'
[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) {
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 === true && this.props.working === false) {
215                 this.setState({ isLoaded: true });
216             }
217             if((this.props.items.length > 0) && !this.state.isLoaded) {
218                 this.setState({ isLoaded: true });
219             }
220             if(this.props.detailsPanelResourceUuid !== this.props.selectedResourceUuid) {
221                 this.props.loadDetailsPanel(this.props.selectedResourceUuid);
222             }
223         }
224
225         componentWillUnmount(): void {
226             this.initializeCheckedList([]);
227         }
228
229         checkBoxColumn: DataColumn<any, any> = {
230             name: "checkBoxColumn",
231             selected: true,
232             configurable: false,
233             filters: createTree(),
234             render: uuid => {
235                 const { classes, checkedList } = this.props;
236                 return (
237                     <div
238                         className={classes.clickBox}
239                         onClick={(ev) => {
240                             ev.stopPropagation()
241                             this.handleSelectOne(uuid)
242                         }}
243                         onDoubleClick={(ev) => ev.stopPropagation()}
244                     >
245                         <input
246                             data-cy={`multiselect-checkbox-${uuid}`}
247                             type='checkbox'
248                             name={uuid}
249                             className={classes.checkBox}
250                             checked={checkedList && checkedList[uuid] ? checkedList[uuid] : false}
251                             onChange={() => this.handleSelectOne(uuid)}
252                             onDoubleClick={(ev) => ev.stopPropagation()}
253                         ></input>
254                     </div>
255                 );
256             },
257         };
258
259         multiselectOptions: DataTableMultiselectOption[] = [
260             { name: "All", fn: list => this.handleSelectAll(list) },
261             { name: "None", fn: list => this.handleSelectNone(list) },
262             { name: "Invert", fn: list => this.handleInvertSelect(list) },
263         ];
264
265         initializeCheckedList = (uuids: any[]): void => {
266             const newCheckedList = { ...this.props.checkedList };
267
268             if(Object.keys(newCheckedList).length === 0){
269                 for(const uuid of uuids){
270                     newCheckedList[uuid] = false
271                 }
272             }
273
274             for (const key in newCheckedList) {
275                 if (!uuids.includes(key)) {
276                     delete newCheckedList[key];
277                 }
278             }
279             this.props.setCheckedListOnStore(newCheckedList);
280         };
281
282         isAllSelected = (list: TCheckedList): boolean => {
283             for (const key in list) {
284                 if (list[key] === false) return false;
285             }
286             return true;
287         };
288
289         isAnySelected = (): boolean => {
290             const { checkedList } = this.props;
291             if (!checkedList) return false;
292             if (!Object.keys(checkedList).length) return false;
293             for (const key in checkedList) {
294                 if (checkedList[key] === true) return true;
295             }
296             return false;
297         };
298
299         handleSelectOne = (uuid: string): void => {
300             const { checkedList } = this.props;
301             const newCheckedList = { ...checkedList };
302             newCheckedList[uuid] = !checkedList[uuid];
303             this.setState({ isSelected: this.isAllSelected(newCheckedList) });
304             this.props.setCheckedListOnStore(newCheckedList);
305         };
306
307         handleSelectorSelect = (): void => {
308             const { checkedList } = this.props;
309             const { isSelected } = this.state;
310             isSelected ? this.handleSelectNone(checkedList) : this.handleSelectAll(checkedList);
311         };
312
313         handleSelectAll = (list: TCheckedList): void => {
314             if (Object.keys(list).length) {
315                 const newCheckedList = { ...list };
316                 for (const key in newCheckedList) {
317                     newCheckedList[key] = true;
318                 }
319                 this.setState({ isSelected: true });
320                 this.props.setCheckedListOnStore(newCheckedList);
321             }
322         };
323
324         handleSelectNone = (list: TCheckedList): void => {
325             const newCheckedList = { ...list };
326             for (const key in newCheckedList) {
327                 newCheckedList[key] = false;
328             }
329             this.setState({ isSelected: false });
330             this.props.setCheckedListOnStore(newCheckedList);
331         };
332
333         handleInvertSelect = (list: TCheckedList): void => {
334             if (Object.keys(list).length) {
335                 const newCheckedList = { ...list };
336                 for (const key in newCheckedList) {
337                     newCheckedList[key] = !list[key];
338                 }
339                 this.setState({ isSelected: this.isAllSelected(newCheckedList) });
340                 this.props.setCheckedListOnStore(newCheckedList);
341             }
342         };
343
344         render() {
345             const { items, classes, columns, isNotFound } = this.props;
346             const { isLoaded } = this.state;
347             if (columns.length && columns[0].name === this.checkBoxColumn.name) columns.shift();
348             columns.unshift(this.checkBoxColumn);
349             return (
350                 <div className={classes.root}>
351                     <div className={classes.content}>
352                         <Table data-cy="data-table" stickyHeader>
353                             <TableHead>
354                                 <TableRow>{this.mapVisibleColumns(this.renderHeadCell)}</TableRow>
355                             </TableHead>
356                             <TableBody className={classes.tableBody}>{(isLoaded && !isNotFound) && items.map(this.renderBodyRow)}</TableBody>
357                         </Table>
358                         {(!isLoaded || isNotFound || items.length === 0) && this.renderNoItemsPlaceholder(this.props.columns)}
359                     </div>
360                 </div>
361             );
362         }
363
364         renderNoItemsPlaceholder = (columns: DataColumns<T, any>) => {
365             const { isLoaded } = this.state;
366             const { working, isNotFound } = this.props;
367             const dirty = columns.some(column => getTreeDirty("")(column.filters));
368             if (isNotFound && isLoaded) {
369                 return (
370                     <DataTableDefaultView
371                         icon={this.props.defaultViewIcon}
372                         messages={["No items found"]}
373                     />
374                 );
375             } else
376             if (isLoaded === false || working === true) {
377                 return (
378                     <DataTableDefaultView
379                         icon={PendingIcon}
380                         messages={["Loading data, please wait"]}
381                     />
382                 );
383             } else {
384                 // isLoaded && !working && !isNotFound
385                 return (
386                     <DataTableDefaultView
387                         icon={this.props.defaultViewIcon}
388                         messages={this.props.defaultViewMessages}
389                         filtersApplied={dirty}
390                     />
391                 );
392             }
393         };
394
395         renderHeadCell = (column: DataColumn<T, any>, index: number) => {
396             const { name, key, renderHeader, filters, sort } = column;
397             const { onSortToggle, onFiltersChange, classes, checkedList } = this.props;
398             const { isSelected } = this.state;
399             return column.name === "checkBoxColumn" ? (
400                 <TableCell
401                     key={key || index}
402                     className={classes.checkBoxCell}>
403                     <div className={classes.checkBoxHead}>
404                         <Tooltip title={this.state.isSelected ? "Deselect all" : "Select all"}>
405                             <input
406                                 type="checkbox"
407                                 className={classes.checkBox}
408                                 checked={isSelected}
409                                 disabled={!this.props.items.length}
410                                 onChange={this.handleSelectorSelect}></input>
411                         </Tooltip>
412                         <DataTableMultiselectPopover
413                             name={`Options`}
414                             disabled={!this.props.items.length}
415                             options={this.multiselectOptions}
416                             checkedList={checkedList}></DataTableMultiselectPopover>
417                     </div>
418                 </TableCell>
419             ) : (
420                 <TableCell
421                     className={classnames(classes.tableHead, index === 1 ? classes.firstTableHead : '')}
422                     key={key || index}>
423                     {renderHeader ? (
424                         renderHeader()
425                     ) : countNodes(filters) > 0 ? (
426                         <DataTableFiltersPopover
427                             name={`${name} filters`}
428                             mutuallyExclusive={column.mutuallyExclusiveFilters}
429                             onChange={filters => onFiltersChange && onFiltersChange(filters, column)}
430                             filters={filters}>
431                             {name}
432                         </DataTableFiltersPopover>
433                     ) : sort ? (
434                         <TableSortLabel
435                             active={sort.direction !== SortDirection.NONE}
436                             direction={sort.direction !== SortDirection.NONE ? sort.direction : undefined}
437                             IconComponent={this.ArrowIcon}
438                             hideSortIcon
439                             onClick={() => onSortToggle && onSortToggle(column)}>
440                             {name}
441                         </TableSortLabel>
442                     ) : (
443                         <span>{name}</span>
444                     )}
445                 </TableCell>
446             );
447         };
448
449         ArrowIcon = ({ className, ...props }: SvgIconProps) => (
450             <IconButton
451                 data-cy="sort-button"
452                 component="span"
453                 className={this.props.classes.arrowButton}
454                 tabIndex={-1}
455                 size="large">
456                 <ArrowDownwardIcon
457                     {...props}
458                     className={classnames(className, this.props.classes.arrow)}
459                 />
460             </IconButton>
461         );
462
463         renderBodyRow = (item: any, index: number) => {
464             const { onRowClick, onRowDoubleClick, extractKey, classes, selectedResourceUuid, currentRoute } = this.props;
465             const { hoveredIndex } = this.state;
466             const isRowSelected = item === selectedResourceUuid;
467             const getClassnames = (colIndex: number) => {
468                 if(currentRoute === '/workflows') return classes.tableCellWorkflows;
469                 if(colIndex === 0) return classnames(classes.checkBoxCell, isRowSelected ? classes.selected : index === hoveredIndex ? classes.hovered : "");
470                 if(colIndex === 1) return classnames(classes.tableCell, classes.firstTableCell, isRowSelected ? classes.selected : "");
471                 return classnames(classes.tableCell, isRowSelected ? classes.selected : "");
472             };
473             const handleHover = (index: number | null) => {
474                 this.setState({ hoveredIndex: index });
475             }
476
477             return (
478                 <TableRow
479                     data-cy={'data-table-row'}
480                     hover
481                     key={extractKey ? extractKey(item) : index}
482                     onClick={event => onRowClick && onRowClick(event, item)}
483                     onContextMenu={this.handleRowContextMenu(item)}
484                     onDoubleClick={event => onRowDoubleClick && onRowDoubleClick(event, item)}
485                     selected={isRowSelected}
486                     className={isRowSelected ? classes.selected : ""}
487                     onMouseEnter={()=>handleHover(index)}
488                     onMouseLeave={()=>handleHover(null)}
489                 >
490                     {this.mapVisibleColumns((column, colIndex) => (
491                         <TableCell
492                             key={column.key || colIndex}
493                             data-cy={column.key || colIndex}
494                             className={getClassnames(colIndex)}>
495                             {column.render(item)}
496                         </TableCell>
497                     ))}
498                 </TableRow>
499             );
500         };
501
502         mapVisibleColumns = (fn: (column: DataColumn<T, any>, index: number) => React.ReactElement<any>) => {
503             return this.props.columns.filter(column => column.selected).map(fn);
504         };
505
506         handleRowContextMenu = (item: T) => (event: React.MouseEvent<HTMLElement>) => this.props.onContextMenu(event, item);
507     }
508 );