15768: filtering now behaves as specified Arvados-DCO-1.1-Signed-off-by: Lisa Knox...
[arvados-workbench2.git] / 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 {
7     Table,
8     TableBody,
9     TableRow,
10     TableCell,
11     TableHead,
12     TableSortLabel,
13     StyleRulesCallback,
14     Theme,
15     WithStyles,
16     withStyles,
17     IconButton,
18     Tooltip,
19 } from '@material-ui/core';
20 import classnames from 'classnames';
21 import { DataColumn, 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-tree';
24 import { DataTableMultiselectPopover } from '../data-table-multiselect-popover/data-table-multiselect-popover';
25 import { DataTableFiltersPopover } from '../data-table-filters/data-table-filters-popover';
26 import { countNodes, getTreeDirty } from 'models/tree';
27 import { IconType, PendingIcon } from 'components/icon/icon';
28 import { SvgIconProps } from '@material-ui/core/SvgIcon';
29 import ArrowDownwardIcon from '@material-ui/icons/ArrowDownward';
30 import { createTree } from 'models/tree';
31 import arraysAreCongruent from 'validators/arrays-are-congruent';
32 import { DataTableMultiselectOption } from '../data-table-multiselect-popover/data-table-multiselect-popover';
33
34 export type DataColumns<I, R> = Array<DataColumn<I, R>>;
35
36 export enum DataTableFetchMode {
37     PAGINATED,
38     INFINITE,
39 }
40
41 export interface DataTableDataProps<I> {
42     items: I[];
43     columns: DataColumns<I, any>;
44     onRowClick: (event: React.MouseEvent<HTMLTableRowElement>, item: I) => void;
45     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: I) => void;
46     onRowDoubleClick: (event: React.MouseEvent<HTMLTableRowElement>, item: I) => void;
47     onSortToggle: (column: DataColumn<I, any>) => void;
48     onFiltersChange: (filters: DataTableFilters, column: DataColumn<I, any>) => void;
49     extractKey?: (item: I) => React.Key;
50     working?: boolean;
51     defaultViewIcon?: IconType;
52     defaultViewMessages?: string[];
53     currentItemUuid?: string;
54     currentRoute?: string;
55 }
56
57 type CssRules =
58     | 'tableBody'
59     | 'root'
60     | 'content'
61     | 'noItemsInfo'
62     | 'checkBoxHead'
63     | 'checkBoxCell'
64     | 'checkBox'
65     | 'tableCell'
66     | 'arrow'
67     | 'arrowButton'
68     | 'tableCellWorkflows'
69     | 'loader';
70
71 const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
72     root: {
73         width: '100%',
74     },
75     content: {
76         display: 'inline-block',
77         width: '100%',
78     },
79     tableBody: {
80         background: theme.palette.background.paper,
81     },
82     loader: {
83         left: '50%',
84         marginLeft: '-84px',
85         position: 'absolute',
86     },
87     noItemsInfo: {
88         textAlign: 'center',
89         padding: theme.spacing.unit,
90     },
91     checkBoxHead: {
92         padding: '0',
93         display: 'flex',
94     },
95     checkBoxCell: {
96         padding: '0',
97         paddingLeft: '10px',
98     },
99     checkBox: {
100         cursor: 'pointer',
101     },
102     tableCell: {
103         wordWrap: 'break-word',
104         paddingRight: '24px',
105         color: '#737373',
106     },
107     tableCellWorkflows: {
108         '&:nth-last-child(2)': {
109             padding: '0px',
110             maxWidth: '48px',
111         },
112         '&:last-child': {
113             padding: '0px',
114             paddingRight: '24px',
115             width: '48px',
116         },
117     },
118     arrow: {
119         margin: 0,
120     },
121     arrowButton: {
122         color: theme.palette.text.primary,
123     },
124 });
125
126 type DataTableState = {
127     isSelected: boolean;
128     checkedList: Record<string, boolean>;
129 };
130
131 const multiselectOptions: DataTableMultiselectOption[] = [
132     { name: 'First Option', fn: (checkedList) => console.log('one', checkedList) },
133     { name: 'Second Option', fn: (checkedList) => console.log('two', checkedList) },
134     { name: 'Third Option', fn: (checkedList) => console.log('three', checkedList) },
135 ];
136
137 type DataTableProps<T> = DataTableDataProps<T> & WithStyles<CssRules>;
138
139 export const DataTable = withStyles(styles)(
140     class Component<T> extends React.Component<DataTableProps<T>> {
141         state: DataTableState = {
142             isSelected: false,
143             checkedList: {},
144         };
145
146         componentDidMount(): void {
147             this.initializeCheckedList(this.props.items);
148         }
149
150         componentDidUpdate(prevProps: Readonly<DataTableProps<T>>, prevState: DataTableState) {
151             const { items } = this.props;
152             const { checkedList } = this.state;
153             if (!arraysAreCongruent(prevProps.items, items)) {
154                 this.state.isSelected = false;
155                 this.initializeCheckedList(items);
156             }
157             if (prevState.checkedList !== checkedList) {
158                 window.localStorage.setItem('selectedRows', JSON.stringify(checkedList));
159             }
160         }
161
162         checkBoxColumn: DataColumn<any, any> = {
163             name: 'checkBoxColumn',
164             selected: true,
165             configurable: false,
166             filters: createTree(),
167             render: (uuid) => (
168                 <input
169                     type='checkbox'
170                     name={uuid}
171                     className={this.props.classes.checkBox}
172                     checked={this.state.checkedList[uuid] ?? false}
173                     onChange={() => this.handleCheck(uuid)}
174                     onDoubleClick={(ev) => ev.stopPropagation()}
175                 ></input>
176             ),
177         };
178
179         initializeCheckedList = (uuids: any[]): void => {
180             const { checkedList } = this.state;
181             uuids.forEach((uuid) => {
182                 if (!checkedList.hasOwnProperty(uuid)) {
183                     checkedList[uuid] = false;
184                 }
185             });
186             for (const key in checkedList) {
187                 if (!uuids.includes(key)) {
188                     delete checkedList[key];
189                 }
190             }
191             window.localStorage.setItem('selectedRows', JSON.stringify(checkedList));
192         };
193
194         handleSelectorSelect = (): void => {
195             const { isSelected, checkedList } = this.state;
196             const newCheckedList = { ...checkedList };
197             for (const key in newCheckedList) {
198                 newCheckedList[key] = !isSelected;
199             }
200             this.setState({ isSelected: !isSelected, checkedList: newCheckedList });
201         };
202
203         handleCheck = (uuid: string): void => {
204             const { checkedList } = this.state;
205             const newCheckedList = { ...checkedList };
206             newCheckedList[uuid] = !checkedList[uuid];
207             this.setState({ checkedList: newCheckedList });
208         };
209
210         handleInvertSelect = (): void => {
211             const { checkedList } = this.state;
212             const newCheckedList = { ...checkedList };
213             for (const key in newCheckedList) {
214                 newCheckedList[key] = !checkedList[key];
215             }
216             this.setState({ checkedList: newCheckedList });
217         };
218
219         render() {
220             const { items, classes, working, columns } = this.props;
221             if (columns[0].name === this.checkBoxColumn.name) columns.shift();
222             columns.unshift(this.checkBoxColumn);
223             return (
224                 <div className={classes.root}>
225                     <div className={classes.content}>
226                         <Table>
227                             <TableHead>
228                                 <TableRow>{this.mapVisibleColumns(this.renderHeadCell)}</TableRow>
229                             </TableHead>
230                             <TableBody className={classes.tableBody}>{!working && items.map(this.renderBodyRow)}</TableBody>
231                         </Table>
232                         {!!working && (
233                             <div className={classes.loader}>
234                                 <DataTableDefaultView icon={PendingIcon} messages={['Loading data, please wait.']} />
235                             </div>
236                         )}
237                         {items.length === 0 && !working && this.renderNoItemsPlaceholder(this.props.columns)}
238                     </div>
239                 </div>
240             );
241         }
242
243         renderNoItemsPlaceholder = (columns: DataColumns<T, any>) => {
244             const dirty = columns.some((column) => getTreeDirty('')(column.filters));
245             return <DataTableDefaultView icon={this.props.defaultViewIcon} messages={this.props.defaultViewMessages} filtersApplied={dirty} />;
246         };
247
248         renderHeadCell = (column: DataColumn<T, any>, index: number) => {
249             const { name, key, renderHeader, filters, sort } = column;
250             const { onSortToggle, onFiltersChange, classes } = this.props;
251             return column.name === 'checkBoxColumn' ? (
252                 <TableCell key={key || index} className={classes.checkBoxCell}>
253                     <div className={classes.checkBoxHead}>
254                         <Tooltip title={this.state.isSelected ? 'Deselect All' : 'Select All'}>
255                             <input type='checkbox' className={classes.checkBox} checked={this.state.isSelected} onChange={this.handleSelectorSelect}></input>
256                         </Tooltip>
257                         <DataTableMultiselectPopover name={`Options`} options={multiselectOptions} checkedList={this.state.checkedList}></DataTableMultiselectPopover>
258                     </div>
259                 </TableCell>
260             ) : (
261                 <TableCell className={classes.tableCell} key={key || index}>
262                     {renderHeader ? (
263                         renderHeader()
264                     ) : countNodes(filters) > 0 ? (
265                         <DataTableFiltersPopover
266                             name={`${name} filters`}
267                             mutuallyExclusive={column.mutuallyExclusiveFilters}
268                             onChange={(filters) => onFiltersChange && onFiltersChange(filters, column)}
269                             filters={filters}
270                         >
271                             {name}
272                         </DataTableFiltersPopover>
273                     ) : sort ? (
274                         <TableSortLabel
275                             active={sort.direction !== SortDirection.NONE}
276                             direction={sort.direction !== SortDirection.NONE ? sort.direction : undefined}
277                             IconComponent={this.ArrowIcon}
278                             hideSortIcon
279                             onClick={() => onSortToggle && onSortToggle(column)}
280                         >
281                             {name}
282                         </TableSortLabel>
283                     ) : (
284                         <span>{name}</span>
285                     )}
286                 </TableCell>
287             );
288         };
289
290         ArrowIcon = ({ className, ...props }: SvgIconProps) => (
291             <IconButton component='span' className={this.props.classes.arrowButton} tabIndex={-1}>
292                 <ArrowDownwardIcon {...props} className={classnames(className, this.props.classes.arrow)} />
293             </IconButton>
294         );
295
296         renderBodyRow = (item: any, index: number) => {
297             const { onRowClick, onRowDoubleClick, extractKey, classes, currentItemUuid, currentRoute } = this.props;
298             return (
299                 <TableRow
300                     hover
301                     key={extractKey ? extractKey(item) : index}
302                     onClick={(event) => onRowClick && onRowClick(event, item)}
303                     onContextMenu={this.handleRowContextMenu(item)}
304                     onDoubleClick={(event) => onRowDoubleClick && onRowDoubleClick(event, item)}
305                     selected={item === currentItemUuid}
306                 >
307                     {this.mapVisibleColumns((column, index) => (
308                         <TableCell
309                             key={column.key || index}
310                             className={currentRoute === '/workflows' ? classes.tableCellWorkflows : index === 0 ? classes.checkBoxCell : classes.tableCell}
311                         >
312                             {column.render(item)}
313                         </TableCell>
314                     ))}
315                 </TableRow>
316             );
317         };
318
319         mapVisibleColumns = (fn: (column: DataColumn<T, any>, index: number) => React.ReactElement<any>) => {
320             return this.props.columns.filter((column) => column.selected).map(fn);
321         };
322
323         handleRowContextMenu = (item: T) => (event: React.MouseEvent<HTMLElement>) => this.props.onContextMenu(event, item);
324     }
325 );