21842: fixed lost anchorEl bug
[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.containerRef.current || 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             ev.preventDefault();
240             this.updateSelectedSuggestionIndex(-1);
241         } else if (ev.key === 'ArrowDown') {
242             ev.preventDefault();
243             this.updateSelectedSuggestionIndex(1);
244         }
245     }
246
247     updateSelectedSuggestionIndex(value: -1 | 1) {
248         const { suggestions = [], category } = this.props;
249         const { tabbedListContents, selectedTab } = this.state;
250         const tabLabels = Object.keys(tabbedListContents);
251         this.setState(({ selectedSuggestionIndex }) => ({
252             selectedSuggestionIndex: (selectedSuggestionIndex + value) % (category === AutocompleteCat.SHARING 
253                     ? tabbedListContents[tabLabels[selectedTab]].length 
254                     : suggestions.length)
255         }));
256     }
257
258     renderChips() {
259         const { items, onDelete } = this.props;
260
261         /**
262          * If input startAdornment prop is not undefined, input's label will stay above the input.
263          * If there is not items, we want the label to go back to placeholder position.
264          * That why we return without a value instead of returning a result of a _map_ which is an empty array.
265          */
266         if (items.length === 0) {
267             return;
268         }
269
270         return items.map(
271             (item, index) => {
272                 const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : '';
273                 if (tooltip && tooltip.length) {
274                     return <span key={index}>
275                         <Tooltip title={tooltip}>
276                         <Chip
277                             label={this.renderChipValue(item)}
278                             key={index}
279                             onDelete={onDelete && !this.props.disabled ? (() =>  onDelete(item, index)) : undefined} />
280                     </Tooltip></span>
281                 } else {
282                     return <span key={index}><Chip
283                         label={this.renderChipValue(item)}
284                         onDelete={onDelete && !this.props.disabled ? (() =>  onDelete(item, index)) : undefined} /></span>
285                 }
286             }
287         );
288     }
289
290     renderChipValue(value: Value) {
291         const { renderChipValue } = this.props;
292         return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
293     }
294
295     preventBlur = (event: React.MouseEvent<HTMLElement>) => {
296         event.preventDefault();
297     }
298
299     handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
300         if (event.target !== this.inputRef.current) {
301             this.setState({ suggestionsOpen: false });
302         }
303     }
304
305     handleSelect = (suggestion: Suggestion) => {
306         return () => {
307             const { onSelect = noop } = this.props;
308             const { current } = this.inputRef;
309             if (current) {
310                 current.focus();
311             }
312             onSelect(suggestion);
313         };
314     }
315
316     renderSuggestion(suggestion: Suggestion) {
317         const { renderSuggestion } = this.props;
318         return renderSuggestion
319             ? renderSuggestion(suggestion)
320             : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
321     }
322
323     renderSharingSuggestion(suggestion: Suggestion) {
324         if (isGroup(suggestion)) {
325             return <ListItemText>
326                         <Typography noWrap data-cy="sharing-suggestion">
327                             {(suggestion as any).name}
328                         </Typography>
329                     </ListItemText>;}
330         return <ListItemText>
331                     <Typography data-cy="sharing-suggestion">
332                         {`${(suggestion as any).fullName} (${(suggestion as any).username})`}
333                     </Typography>
334                 </ListItemText>;
335     }
336
337     getSuggestionsWidth() {
338         return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
339     }
340 });
341
342 type ChipClasses = 'root';
343
344 const chipStyles: CustomStyleRulesCallback<ChipClasses> = theme => ({
345     root: {
346         marginRight: theme.spacing(0.25),
347         height: theme.spacing(3),
348     }
349 });
350
351 const Chip = withStyles(chipStyles)(MuiChip);
352
353 type PopperClasses = 'root';
354
355 const popperStyles: CustomStyleRulesCallback<ChipClasses> = theme => ({
356     root: {
357         zIndex: theme.zIndex.modal,
358     }
359 });
360
361 const Popper = withStyles(popperStyles)(
362     ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
363         <MuiPopper {...props} className={classes.root} />
364 );
365
366 type InputClasses = 'root';
367
368 const inputStyles: CustomStyleRulesCallback<InputClasses> = () => ({
369     root: {
370         display: 'flex',
371         flexWrap: 'wrap',
372     },
373     input: {
374         minWidth: '20%',
375         flex: 1,
376     },
377 });
378
379 const Input = withStyles(inputStyles)(MuiInput);
380
381 const Paper = withStyles({
382     root: {
383         maxHeight: '80vh',
384         overflowY: 'auto',
385     }
386 })(MuiPaper);