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