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