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