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