21842: tabbedList opens on input focus with spinner and 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,
12     InputLabel,
13     ListItemText,
14     ListItem,
15     List,
16     FormHelperText,
17     Tooltip,
18     Typography,
19 } from '@mui/material';
20 import withStyles from '@mui/styles/withStyles';
21 import { CustomStyleRulesCallback } from 'common/custom-theme';
22 import { PopperProps } from '@mui/material/Popper';
23 import { WithStyles } from '@mui/styles';
24 import { noop } from 'lodash';
25 import { isGroup } from 'common/isGroup';
26 import { sortByKey } from 'common/objects';
27 import { TabbedList } from 'components/tabbedList/tabbed-list';
28
29 export interface AutocompleteProps<Item, Suggestion> {
30     label?: string;
31     value: string;
32     items: Item[];
33     disabled?: boolean;
34     suggestions?: Suggestion[];
35     error?: boolean;
36     helperText?: string;
37     autofocus?: boolean;
38     onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
39     onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
40     onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
41     onCreate?: () => void;
42     onDelete?: (item: Item, index: number) => void;
43     onSelect?: (suggestion: Suggestion) => void;
44     renderChipValue?: (item: Item) => string;
45     renderChipTooltip?: (item: Item) => string;
46     renderSuggestion?: (suggestion: Suggestion) => React.ReactNode;
47     category?: AutocompleteCat;
48     isWorking?: boolean;
49 }
50
51 type AutocompleteClasses = 'tabbedListStyles';
52
53 const autocompleteStyles: CustomStyleRulesCallback<AutocompleteClasses> = theme => ({
54     tabbedListStyles: {
55         maxHeight: '18rem',
56     }
57 });
58
59 export enum AutocompleteCat {
60     SHARING = 'sharing',
61 };
62
63 export interface AutocompleteState {
64     suggestionsOpen: boolean;
65     selectedTab: number;
66     selectedSuggestionIndex: number;
67     tabbedListContents: Record<string, any[]>;
68 }
69
70 export const Autocomplete = withStyles(autocompleteStyles)(
71     class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion> & WithStyles<AutocompleteClasses>, AutocompleteState> {
72
73     state = {
74         suggestionsOpen: false,
75         selectedTab: 0,
76         selectedSuggestionIndex: 0,
77         tabbedListContents: {},
78     };
79
80     componentDidUpdate(prevProps: AutocompleteProps<Value, Suggestion>, prevState: AutocompleteState) {
81         const { suggestions = [], category } = this.props;
82             if( prevProps.suggestions?.length === 0 && suggestions.length > 0) {
83                 this.setState({ selectedSuggestionIndex: 0, selectedTab: 0 });
84             }
85             if (category === AutocompleteCat.SHARING) {
86                 if (Object.keys(this.state.tabbedListContents).length === 0) {
87                     this.setState({ tabbedListContents: { groups: [], users: [] } });
88                 }
89                 if (prevProps.suggestions !== suggestions) {
90                     const users = sortByKey<Suggestion>(suggestions.filter(item => !isGroup(item)), 'fullName');
91                     const groups = sortByKey<Suggestion>(suggestions.filter(item => isGroup(item)), 'name');
92                     this.setState({ tabbedListContents: { groups: groups, users: users } });
93                 }
94                 if (prevState.selectedTab !== this.state.selectedTab) {
95                     this.setState({ selectedSuggestionIndex: 0 });
96                 }
97             }
98     }
99
100     containerRef = React.createRef<HTMLDivElement>();
101     inputRef = React.createRef<HTMLInputElement>();
102
103     render() {
104         return <div ref={this.containerRef}>
105                     <FormControl variant="standard" fullWidth error={this.props.error}>
106                         {this.renderLabel()}
107                         {this.renderInput()}
108                         {this.renderHelperText()}
109                         {this.props.category === AutocompleteCat.SHARING ? this.renderTabbedSuggestions() : this.renderSuggestions()}
110                     </FormControl>
111                </div>
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.props.autofocus}
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     renderTabbedSuggestions() {
164         const { suggestions = [], classes } = this.props;
165
166         const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
167             event.preventDefault();
168             this.setState({ selectedTab: newValue });
169         };
170         
171         return (
172             <Popper
173                 open={this.state.suggestionsOpen}
174                 anchorEl={this.inputRef.current}
175                 key={suggestions.length}
176                 style={{ width: this.getSuggestionsWidth()}}
177             >
178                 <Paper onMouseDown={this.preventBlur}>
179                     <TabbedList 
180                         tabbedListContents={this.state.tabbedListContents} 
181                         renderListItem={this.renderSharingSuggestion} 
182                         injectedStyles={classes.tabbedListStyles}
183                         selectedIndex={this.state.selectedSuggestionIndex}
184                         selectedTab={this.state.selectedTab}
185                         handleTabChange={handleTabChange}
186                         handleSelect={this.handleSelect}
187                         includeContentsLength={true}
188                         isWorking={this.props.isWorking}
189                         />
190                 </Paper>
191             </Popper>
192         );
193     }
194
195     isSuggestionBoxOpen() {
196         const { suggestions = [] } = this.props;
197         return this.state.suggestionsOpen && suggestions.length > 0;
198     }
199
200     handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
201         const { onFocus = noop } = this.props;
202         this.setState({ suggestionsOpen: true });
203         onFocus(event);
204     }
205
206     handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
207         setTimeout(() => {
208             const { onBlur = noop } = this.props;
209             this.setState({ suggestionsOpen: false });
210             onBlur(event);
211         });
212     }
213
214     handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
215         const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props;
216         const { selectedSuggestionIndex, selectedTab } = this.state;
217         if (event.key === 'Enter') {
218             if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
219                 // prevent form submissions when selecting a suggestion
220                 event.preventDefault();
221                 if(this.props.category === AutocompleteCat.SHARING) {
222                     onSelect(this.state.tabbedListContents[Object.keys(this.state.tabbedListContents)[selectedTab]][selectedSuggestionIndex]);
223                 } else {
224                     onSelect(suggestions[selectedSuggestionIndex]);
225                 }
226             } else if (this.props.value.length > 0) {
227                 onCreate();
228             }
229         }
230     }
231
232     handleNavigationKeyPress = (ev: React.KeyboardEvent<HTMLInputElement>) => {
233         if (ev.key === 'Tab' && this.isSuggestionBoxOpen()) {
234             ev.preventDefault();
235             // Cycle through tabs, or loop back to the first tab
236             this.setState({ selectedTab: ((this.state.selectedTab + 1) % Object.keys(this.state.tabbedListContents).length)} || 0)
237         }
238         if (ev.key === 'ArrowUp') {
239             this.updateSelectedSuggestionIndex(-1);
240         } else if (ev.key === 'ArrowDown') {
241             this.updateSelectedSuggestionIndex(1);
242         }
243     }
244
245     updateSelectedSuggestionIndex(value: -1 | 1) {
246         const { suggestions = [], category } = this.props;
247         const { tabbedListContents, selectedTab } = this.state;
248         const tabLabels = Object.keys(tabbedListContents);
249         this.setState(({ selectedSuggestionIndex }) => ({
250             selectedSuggestionIndex: (selectedSuggestionIndex + value) % (category === AutocompleteCat.SHARING 
251                     ? tabbedListContents[tabLabels[selectedTab]].length 
252                     : suggestions.length)
253         }));
254     }
255
256     renderChips() {
257         const { items, onDelete } = this.props;
258
259         /**
260          * If input startAdornment prop is not undefined, input's label will stay above the input.
261          * If there is not items, we want the label to go back to placeholder position.
262          * That why we return without a value instead of returning a result of a _map_ which is an empty array.
263          */
264         if (items.length === 0) {
265             return;
266         }
267
268         return items.map(
269             (item, index) => {
270                 const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : '';
271                 if (tooltip && tooltip.length) {
272                     return <span key={index}>
273                         <Tooltip title={tooltip}>
274                         <Chip
275                             label={this.renderChipValue(item)}
276                             key={index}
277                             onDelete={onDelete && !this.props.disabled ? (() =>  onDelete(item, index)) : undefined} />
278                     </Tooltip></span>
279                 } else {
280                     return <span key={index}><Chip
281                         label={this.renderChipValue(item)}
282                         onDelete={onDelete && !this.props.disabled ? (() =>  onDelete(item, index)) : undefined} /></span>
283                 }
284             }
285         );
286     }
287
288     renderChipValue(value: Value) {
289         const { renderChipValue } = this.props;
290         return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
291     }
292
293     preventBlur = (event: React.MouseEvent<HTMLElement>) => {
294         event.preventDefault();
295     }
296
297     handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
298         if (event.target !== this.inputRef.current) {
299             this.setState({ suggestionsOpen: false });
300         }
301     }
302
303     handleSelect = (suggestion: Suggestion) => {
304         return () => {
305             const { onSelect = noop } = this.props;
306             const { current } = this.inputRef;
307             if (current) {
308                 current.focus();
309             }
310             onSelect(suggestion);
311         };
312     }
313
314     renderSuggestion(suggestion: Suggestion) {
315         const { renderSuggestion } = this.props;
316         return renderSuggestion
317             ? renderSuggestion(suggestion)
318             : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
319     }
320
321     renderSharingSuggestion(suggestion: Suggestion) {
322         if (isGroup(suggestion)) {
323             return <ListItemText>
324                         <Typography noWrap data-cy="sharing-suggestion">
325                             {(suggestion as any).name}
326                         </Typography>
327                     </ListItemText>;}
328         return <ListItemText>
329                     <Typography data-cy="sharing-suggestion">
330                         {`${(suggestion as any).fullName} (${(suggestion as any).username})`}
331                     </Typography>
332                 </ListItemText>;
333     }
334
335     getSuggestionsWidth() {
336         return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
337     }
338 });
339
340 type ChipClasses = 'root';
341
342 const chipStyles: CustomStyleRulesCallback<ChipClasses> = theme => ({
343     root: {
344         marginRight: theme.spacing(0.25),
345         height: theme.spacing(3),
346     }
347 });
348
349 const Chip = withStyles(chipStyles)(MuiChip);
350
351 type PopperClasses = 'root';
352
353 const popperStyles: CustomStyleRulesCallback<ChipClasses> = theme => ({
354     root: {
355         zIndex: theme.zIndex.modal,
356     }
357 });
358
359 const Popper = withStyles(popperStyles)(
360     ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
361         <MuiPopper {...props} className={classes.root} />
362 );
363
364 type InputClasses = 'root';
365
366 const inputStyles: CustomStyleRulesCallback<InputClasses> = () => ({
367     root: {
368         display: 'flex',
369         flexWrap: 'wrap',
370     },
371     input: {
372         minWidth: '20%',
373         flex: 1,
374     },
375 });
376
377 const Input = withStyles(inputStyles)(MuiInput);
378
379 const Paper = withStyles({
380     root: {
381         maxHeight: '80vh',
382         overflowY: 'auto',
383     }
384 })(MuiPaper);