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