15768: multiselect popover pops over Arvados-DCO-1.1-Signed-off-by: Lisa Knox <lisa...
[arvados-workbench2.git] / src / components / data-table-multiselect-popover / data-table-multiselect-popover.tsx
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import React, { useEffect } from 'react';
6 import {
7     WithStyles,
8     withStyles,
9     ButtonBase,
10     StyleRulesCallback,
11     Theme,
12     Popover,
13     Button,
14     Card,
15     CardActions,
16     Typography,
17     CardContent,
18     Tooltip,
19     IconButton,
20 } from '@material-ui/core';
21 import classnames from 'classnames';
22 import { DefaultTransformOrigin } from 'components/popover/helpers';
23 import { createTree } from 'models/tree';
24 import { DataTableFilters, DataTableFiltersTree } from './data-table-multiselect-tree';
25 import { getNodeDescendants } from 'models/tree';
26 import debounce from 'lodash/debounce';
27 import { green, grey } from '@material-ui/core/colors';
28
29 export type CssRules = 'root' | 'icon' | 'iconButton' | 'active' | 'checkbox';
30
31 const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
32     root: {
33         // border: '1px dashed green',
34         margin: 0,
35         borderRadius: '7px',
36         '&:hover': {
37             backgroundColor: grey[200],
38         },
39         '&:focus': {
40             color: theme.palette.text.primary,
41         },
42     },
43     active: {
44         color: theme.palette.text.primary,
45         '& $iconButton': {
46             opacity: 1,
47         },
48     },
49     icon: {
50         // border: '1px solid red',
51         cursor: 'pointer',
52         fontSize: 20,
53         userSelect: 'none',
54         '&:hover': {
55             color: theme.palette.text.primary,
56         },
57     },
58     iconButton: {
59         color: theme.palette.text.primary,
60         opacity: 0.6,
61         padding: 1,
62         paddingBottom: 5,
63     },
64     checkbox: {
65         width: 24,
66         height: 24,
67     },
68 });
69
70 enum SelectionMode {
71     ALL = 'all',
72     NONE = 'none',
73 }
74
75 export interface DataTableFilterProps {
76     name: string;
77     filters: DataTableFilters;
78     onChange?: (filters: DataTableFilters) => void;
79
80     /**
81      * When set to true, only one filter can be selected at a time.
82      */
83     mutuallyExclusive?: boolean;
84
85     /**
86      * By default `all` filters selection means that label should be grayed out.
87      * Use `none` when label is supposed to be grayed out when no filter is selected.
88      */
89     defaultSelection?: SelectionMode;
90 }
91
92 interface DataTableFilterState {
93     anchorEl?: HTMLElement;
94     filters: DataTableFilters;
95     prevFilters: DataTableFilters;
96 }
97
98 export const DataTableMultiselectPopover = withStyles(styles)(
99     class extends React.Component<DataTableFilterProps & WithStyles<CssRules>, DataTableFilterState> {
100         state: DataTableFilterState = {
101             anchorEl: undefined,
102             filters: createTree(),
103             prevFilters: createTree(),
104         };
105         icon = React.createRef<HTMLElement>();
106
107         render() {
108             const { name, classes, defaultSelection = SelectionMode.ALL, children } = this.props;
109             const isActive = getNodeDescendants('')(this.state.filters).some((f) => (defaultSelection === SelectionMode.ALL ? !f.selected : f.selected));
110             return (
111                 <>
112                     <Tooltip disableFocusListener title='Multiselect Actions'>
113                         <ButtonBase className={classnames([classes.root, { [classes.active]: isActive }])} component='span' onClick={this.open} disableRipple>
114                             {children}
115                             <IconButton component='span' classes={{ root: classes.iconButton }} tabIndex={-1}>
116                                 <i className={classnames(['fas fa-sort-down', classes.icon])} data-fa-transform='shrink-3' ref={this.icon} />
117                             </IconButton>
118                         </ButtonBase>
119                     </Tooltip>
120                     <Popover
121                         anchorEl={this.state.anchorEl}
122                         open={!!this.state.anchorEl}
123                         anchorOrigin={DefaultTransformOrigin}
124                         transformOrigin={DefaultTransformOrigin}
125                         onClose={this.close}
126                     >
127                         <Card>
128                             <CardContent>
129                                 <Typography variant='caption'>{'foo'}</Typography>
130                             </CardContent>
131                             <DataTableFiltersTree filters={this.state.filters} mutuallyExclusive={this.props.mutuallyExclusive} onChange={this.onChange} />
132                             {this.props.mutuallyExclusive || (
133                                 <CardActions>
134                                     <Button color='primary' variant='outlined' size='small' onClick={this.close}>
135                                         Close
136                                     </Button>
137                                 </CardActions>
138                             )}
139                         </Card>
140                     </Popover>
141                     <this.MountHandler />
142                 </>
143             );
144         }
145
146         static getDerivedStateFromProps(props: DataTableFilterProps, state: DataTableFilterState): DataTableFilterState {
147             return props.filters !== state.prevFilters ? { ...state, filters: props.filters, prevFilters: props.filters } : state;
148         }
149
150         open = () => {
151             this.setState({ anchorEl: this.icon.current || undefined });
152         };
153
154         onChange = (filters) => {
155             this.setState({ filters });
156             if (this.props.mutuallyExclusive) {
157                 // Mutually exclusive filters apply immediately
158                 const { onChange } = this.props;
159                 if (onChange) {
160                     onChange(filters);
161                 }
162                 this.close();
163             } else {
164                 // Non-mutually exclusive filters are debounced
165                 this.submit();
166             }
167         };
168
169         submit = debounce(() => {
170             const { onChange } = this.props;
171             if (onChange) {
172                 onChange(this.state.filters);
173             }
174         }, 1000);
175
176         MountHandler = () => {
177             useEffect(() => {
178                 return () => {
179                     this.submit.cancel();
180                 };
181             }, []);
182             return null;
183         };
184
185         close = () => {
186             this.setState((prev) => ({
187                 ...prev,
188                 anchorEl: undefined,
189             }));
190         };
191     }
192 );