Merge branch '21128-toolbar-context-menu'
[arvados-workbench2.git] / src / components / data-table-filters / data-table-filters-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-filters-tree';
25 import { getNodeDescendants } from 'models/tree';
26 import debounce from 'lodash/debounce';
27
28 export type CssRules = 'root' | 'icon' | 'iconButton' | 'active' | 'checkbox';
29
30 const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
31     root: {
32         cursor: 'pointer',
33         display: 'inline-flex',
34         justifyContent: 'flex-start',
35         flexDirection: 'inherit',
36         alignItems: 'center',
37         '&:hover': {
38             color: theme.palette.text.primary,
39         },
40         '&:focus': {
41             color: theme.palette.text.primary,
42         },
43     },
44     active: {
45         color: theme.palette.text.primary,
46         '& $iconButton': {
47             opacity: 1,
48         },
49     },
50     icon: {
51         fontSize: 12,
52         userSelect: 'none',
53         width: 16,
54         height: 15,
55         marginTop: 1,
56     },
57     iconButton: {
58         color: theme.palette.text.primary,
59         opacity: 0.7,
60     },
61     checkbox: {
62         width: 24,
63         height: 24,
64     },
65 });
66
67 enum SelectionMode {
68     ALL = 'all',
69     NONE = 'none',
70 }
71
72 export interface DataTableFilterProps {
73     name: string;
74     filters: DataTableFilters;
75     onChange?: (filters: DataTableFilters) => void;
76
77     /**
78      * When set to true, only one filter can be selected at a time.
79      */
80     mutuallyExclusive?: boolean;
81
82     /**
83      * By default `all` filters selection means that label should be grayed out.
84      * Use `none` when label is supposed to be grayed out when no filter is selected.
85      */
86     defaultSelection?: SelectionMode;
87 }
88
89 interface DataTableFilterState {
90     anchorEl?: HTMLElement;
91     filters: DataTableFilters;
92     prevFilters: DataTableFilters;
93 }
94
95 export const DataTableFiltersPopover = withStyles(styles)(
96     class extends React.Component<DataTableFilterProps & WithStyles<CssRules>, DataTableFilterState> {
97         state: DataTableFilterState = {
98             anchorEl: undefined,
99             filters: createTree(),
100             prevFilters: createTree(),
101         };
102         icon = React.createRef<HTMLElement>();
103
104         render() {
105             const { name, classes, defaultSelection = SelectionMode.ALL, children } = this.props;
106             const isActive = getNodeDescendants('')(this.state.filters).some((f) => (defaultSelection === SelectionMode.ALL ? !f.selected : f.selected));
107             return (
108                 <>
109                     <Tooltip disableFocusListener title='Filters'>
110                         <ButtonBase className={classnames([classes.root, { [classes.active]: isActive }])} component='span' onClick={this.open} disableRipple>
111                             {children}
112                             <IconButton component='span' classes={{ root: classes.iconButton }} tabIndex={-1}>
113                                 <i className={classnames(['fas fa-filter', classes.icon])} data-fa-transform='shrink-3' ref={this.icon} />
114                             </IconButton>
115                         </ButtonBase>
116                     </Tooltip>
117                     <Popover
118                         anchorEl={this.state.anchorEl}
119                         open={!!this.state.anchorEl}
120                         anchorOrigin={DefaultTransformOrigin}
121                         transformOrigin={DefaultTransformOrigin}
122                         onClose={this.close}
123                     >
124                         <Card>
125                             <CardContent>
126                                 <Typography variant='caption'>{name}</Typography>
127                             </CardContent>
128                             <DataTableFiltersTree filters={this.state.filters} mutuallyExclusive={this.props.mutuallyExclusive} onChange={this.onChange} />
129                             <>
130                                 {this.props.mutuallyExclusive || (
131                                     <CardActions>
132                                         <Button color='primary' variant='outlined' size='small' onClick={this.close}>
133                                             Close
134                                         </Button>
135                                     </CardActions>
136                                 )}
137                             </>
138                         </Card>
139                     </Popover>
140                     <this.MountHandler />
141                 </>
142             );
143         }
144
145         static getDerivedStateFromProps(props: DataTableFilterProps, state: DataTableFilterState): DataTableFilterState {
146             return props.filters !== state.prevFilters ? { ...state, filters: props.filters, prevFilters: props.filters } : state;
147         }
148
149         open = () => {
150             this.setState({ anchorEl: this.icon.current || undefined });
151         };
152
153         onChange = (filters) => {
154             this.setState({ filters });
155             if (this.props.mutuallyExclusive) {
156                 // Mutually exclusive filters apply immediately
157                 const { onChange } = this.props;
158                 if (onChange) {
159                     onChange(filters);
160                 }
161                 this.close();
162             } else {
163                 // Non-mutually exclusive filters are debounced
164                 this.submit();
165             }
166         };
167
168         submit = debounce(() => {
169             const { onChange } = this.props;
170             if (onChange) {
171                 onChange(this.state.filters);
172             }
173         }, 1000);
174
175         MountHandler = () => {
176             useEffect(() => {
177                 return () => {
178                     this.submit.cancel();
179                 };
180             }, []);
181             return null;
182         };
183
184         close = () => {
185             this.setState((prev) => ({
186                 ...prev,
187                 anchorEl: undefined,
188             }));
189         };
190     }
191 );