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