21842: added mui withstyles
[arvados.git] / services / workbench2 / src / components / autocomplete / autocomplete.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     Input as MuiInput,
8     Chip as MuiChip,
9     Popper as MuiPopper,
10     Paper as MuiPaper,
11     FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List, FormHelperText, Tooltip, Typography
12 } from '@material-ui/core';
13 import { PopperProps } from '@material-ui/core/Popper';
14 import { WithStyles } from '@material-ui/core/styles';
15 import { noop } from 'lodash';
16 import { isGroup } from 'common/isGroup';
17 import { sortByKey } from 'common/objects';
18 import classNames from 'classnames';
19
20 export interface AutocompleteProps<Item, Suggestion> {
21     label?: string;
22     value: string;
23     items: Item[];
24     disabled?: boolean;
25     suggestions?: Suggestion[];
26     error?: boolean;
27     helperText?: string;
28     autofocus?: boolean;
29     onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
30     onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
31     onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
32     onCreate?: () => void;
33     onDelete?: (item: Item, index: number) => void;
34     onSelect?: (suggestion: Suggestion) => void;
35     renderChipValue?: (item: Item) => string;
36     renderChipTooltip?: (item: Item) => string;
37     renderSuggestion?: (suggestion: Suggestion) => React.ReactNode;
38     category?: AutocompleteCat;
39 }
40
41 type AutocompleteClasses = 'sharingList' | 'emptyList';
42
43 const autocompleteStyles: StyleRulesCallback<AutocompleteClasses> = theme => ({
44     sharingList: {
45         maxHeight: '8rem', 
46         overflowX: 'scroll',
47     },
48     emptyList: {
49         padding: '0.5rem',
50         fontStyle: 'italic',
51     },
52 });
53
54 export enum AutocompleteCat {
55     SHARING = 'sharing',
56 };
57
58 export interface AutocompleteState {
59     suggestionsOpen: boolean;
60     selectedSuggestionIndex: number;
61 }
62
63 export const Autocomplete = withStyles(autocompleteStyles)(
64     class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion> & WithStyles<AutocompleteClasses>, AutocompleteState> {
65
66     state = {
67         suggestionsOpen: false,
68         selectedSuggestionIndex: 0,
69     };
70
71     containerRef = React.createRef<HTMLDivElement>();
72     inputRef = React.createRef<HTMLInputElement>();
73
74     render() {
75         return (
76             <RootRef rootRef={this.containerRef}>
77                 <FormControl fullWidth error={this.props.error}>
78                     {this.renderLabel()}
79                     {this.renderInput()}
80                     {this.renderHelperText()}
81                     {this.props.category === AutocompleteCat.SHARING ? this.renderSharingSuggestions() : this.renderSuggestions()}
82                 </FormControl>
83             </RootRef>
84         );
85     }
86
87     renderLabel() {
88         const { label } = this.props;
89         return label && <InputLabel>{label}</InputLabel>;
90     }
91
92     renderInput() {
93         return <Input
94             disabled={this.props.disabled}
95             autoFocus={this.props.autofocus}
96             inputRef={this.inputRef}
97             value={this.props.value}
98             startAdornment={this.renderChips()}
99             onFocus={this.handleFocus}
100             onBlur={this.handleBlur}
101             onChange={this.props.onChange}
102             onKeyPress={this.handleKeyPress}
103             onKeyDown={this.handleNavigationKeyPress}
104         />;
105     }
106
107     renderHelperText() {
108         return <FormHelperText>{this.props.helperText}</FormHelperText>;
109     }
110
111     renderSuggestions() {
112         const { suggestions = [] } = this.props;
113         return (
114             <Popper
115                 open={this.isSuggestionBoxOpen()}
116                 anchorEl={this.inputRef.current}
117                 key={suggestions.length}>
118                 <Paper onMouseDown={this.preventBlur}>
119                     <List dense style={{ width: this.getSuggestionsWidth() }}>
120                         {suggestions.map(
121                             (suggestion, index) =>
122                                 <ListItem
123                                     button
124                                     key={index}
125                                     onClick={this.handleSelect(suggestion)}
126                                     selected={index === this.state.selectedSuggestionIndex}>
127                                     {this.renderSuggestion(suggestion)}
128                                 </ListItem>
129                         )}
130                     </List>
131                 </Paper>
132             </Popper>
133         );
134     }
135
136     renderSharingSuggestions() {
137         const { suggestions = [], classes } = this.props;
138         const users = sortByKey<Suggestion>(suggestions.filter(item => !isGroup(item)), 'fullName');
139         const groups = sortByKey<Suggestion>(suggestions.filter(item => isGroup(item)), 'name');
140
141         return (
142             <Popper
143                 open={this.isSuggestionBoxOpen()}
144                 anchorEl={this.inputRef.current}
145                 key={suggestions.length}>
146                 <Paper onMouseDown={this.preventBlur}>
147                 Groups
148                 {!!groups.length ? 
149                     <List dense className={classes.sharingList} style={{width: this.getSuggestionsWidth()}}>
150                         {groups.map(
151                             (suggestion, index) =>
152                                 <ListItem
153                                     button
154                                     id={`groups-${index}`}
155                                     key={`groups-${index}`}
156                                     onClick={this.handleSelect(suggestion)}>
157                                     {this.renderSuggestion(suggestion)}
158                                 </ListItem>
159                         )}
160                     </List> : <Typography variant="caption" className={classes.emptyList}>no groups found</Typography>}
161                 Users
162                 {!!users.length ? 
163                     <List dense className={classes.sharingList} style={{width: this.getSuggestionsWidth()}}>
164                         {users.map(
165                             (suggestion, index) =>
166                                 <ListItem
167                                     button
168                                     id={`users-${index}`}
169                                     key={`users-${index}`}
170                                     onClick={this.handleSelect(suggestion)}>
171                                     {this.renderSuggestion(suggestion)}
172                                 </ListItem>
173                         )}
174                     </List> : <Typography variant="caption" className={classes.emptyList}>no users found</Typography>}
175                 </Paper>
176             </Popper>
177         );
178     }
179
180     isSuggestionBoxOpen() {
181         const { suggestions = [] } = this.props;
182         return this.state.suggestionsOpen && suggestions.length > 0;
183     }
184
185     handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
186         const { onFocus = noop } = this.props;
187         this.setState({ suggestionsOpen: true });
188         onFocus(event);
189     }
190
191     handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
192         setTimeout(() => {
193             const { onBlur = noop } = this.props;
194             this.setState({ suggestionsOpen: false });
195             onBlur(event);
196         });
197     }
198
199     handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
200         const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props;
201         const { selectedSuggestionIndex } = this.state;
202         if (event.key === 'Enter') {
203             if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
204                 // prevent form submissions when selecting a suggestion
205                 event.preventDefault();
206                 onSelect(suggestions[selectedSuggestionIndex]);
207             } else if (this.props.value.length > 0) {
208                 onCreate();
209             }
210         }
211     }
212
213     handleNavigationKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
214         if (key === 'ArrowUp') {
215             this.updateSelectedSuggestionIndex(-1);
216         } else if (key === 'ArrowDown') {
217             this.updateSelectedSuggestionIndex(1);
218         }
219     }
220
221     updateSelectedSuggestionIndex(value: -1 | 1) {
222         const { suggestions = [] } = this.props;
223         this.setState(({ selectedSuggestionIndex }) => ({
224             selectedSuggestionIndex: (selectedSuggestionIndex + value) % suggestions.length
225         }));
226     }
227
228     renderChips() {
229         const { items, onDelete } = this.props;
230
231         /**
232          * If input startAdornment prop is not undefined, input's label will stay above the input.
233          * If there is not items, we want the label to go back to placeholder position.
234          * That why we return without a value instead of returning a result of a _map_ which is an empty array.
235          */
236         if (items.length === 0) {
237             return;
238         }
239
240         return items.map(
241             (item, index) => {
242                 const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : '';
243                 if (tooltip && tooltip.length) {
244                     return <span key={index}>
245                         <Tooltip title={tooltip}>
246                         <Chip
247                             label={this.renderChipValue(item)}
248                             key={index}
249                             onDelete={onDelete && !this.props.disabled ? (() =>  onDelete(item, index)) : undefined} />
250                     </Tooltip></span>
251                 } else {
252                     return <span key={index}><Chip
253                         label={this.renderChipValue(item)}
254                         onDelete={onDelete && !this.props.disabled ? (() =>  onDelete(item, index)) : undefined} /></span>
255                 }
256             }
257         );
258     }
259
260     renderChipValue(value: Value) {
261         const { renderChipValue } = this.props;
262         return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
263     }
264
265     preventBlur = (event: React.MouseEvent<HTMLElement>) => {
266         event.preventDefault();
267     }
268
269     handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
270         if (event.target !== this.inputRef.current) {
271             this.setState({ suggestionsOpen: false });
272         }
273     }
274
275     handleSelect(suggestion: Suggestion) {
276         return () => {
277             const { onSelect = noop } = this.props;
278             const { current } = this.inputRef;
279             if (current) {
280                 current.focus();
281             }
282             onSelect(suggestion);
283         };
284     }
285
286     renderSuggestion(suggestion: Suggestion) {
287         const { renderSuggestion } = this.props;
288         return renderSuggestion
289             ? renderSuggestion(suggestion)
290             : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
291     }
292
293     getSuggestionsWidth() {
294         return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
295     }
296 });
297
298 type ChipClasses = 'root';
299
300 const chipStyles: StyleRulesCallback<ChipClasses> = theme => ({
301     root: {
302         marginRight: theme.spacing.unit / 4,
303         height: theme.spacing.unit * 3,
304     }
305 });
306
307 const Chip = withStyles(chipStyles)(MuiChip);
308
309 type PopperClasses = 'root';
310
311 const popperStyles: StyleRulesCallback<PopperClasses> = theme => ({
312     root: {
313         zIndex: theme.zIndex.modal,
314     }
315 });
316
317 const Popper = withStyles(popperStyles)(
318     ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
319         <MuiPopper {...props} className={classes.root} />
320 );
321
322 type InputClasses = 'root';
323
324 const inputStyles: StyleRulesCallback<InputClasses> = () => ({
325     root: {
326         display: 'flex',
327         flexWrap: 'wrap',
328     },
329     input: {
330         minWidth: '20%',
331         flex: 1,
332     },
333 });
334
335 const Input = withStyles(inputStyles)(MuiInput);
336
337 const Paper = withStyles({
338     root: {
339         maxHeight: '80vh',
340         overflowY: 'auto',
341     }
342 })(MuiPaper);