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