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