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