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