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