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