1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
5 import React from 'react';
11 FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List, FormHelperText, Tooltip, Typography,
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';
20 export interface AutocompleteProps<Item, Suggestion> {
25 suggestions?: Suggestion[];
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;
41 type AutocompleteClasses = 'tabbedListStyles';
43 const autocompleteStyles: StyleRulesCallback<AutocompleteClasses> = theme => ({
49 export enum AutocompleteCat {
53 export interface AutocompleteState {
54 suggestionsOpen: boolean;
56 selectedSuggestionIndex: number;
57 tabbedListContents: Record<string, any[]>;
60 export const Autocomplete = withStyles(autocompleteStyles)(
61 class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion> & WithStyles<AutocompleteClasses>, AutocompleteState> {
64 suggestionsOpen: false,
66 selectedSuggestionIndex: 0,
67 tabbedListContents: {},
70 componentDidUpdate(prevProps: AutocompleteProps<Value, Suggestion>, prevState: AutocompleteState) {
71 const { suggestions = [], category } = this.props;
72 if( prevProps.suggestions?.length === 0 && suggestions.length > 0) {
73 this.setState({ selectedSuggestionIndex: 0, selectedTab: 0 });
75 if (category === AutocompleteCat.SHARING) {
76 if (prevProps.suggestions !== suggestions) {
77 const users = sortByKey<Suggestion>(suggestions.filter(item => !isGroup(item)), 'fullName');
78 const groups = sortByKey<Suggestion>(suggestions.filter(item => isGroup(item)), 'name');
79 this.setState({ tabbedListContents: { groups: groups, users: users } });
81 if (prevState.selectedTab !== this.state.selectedTab) {
82 this.setState({ selectedSuggestionIndex: 0 });
87 containerRef = React.createRef<HTMLDivElement>();
88 inputRef = React.createRef<HTMLInputElement>();
92 <RootRef rootRef={this.containerRef}>
93 <FormControl fullWidth error={this.props.error}>
96 {this.renderHelperText()}
97 {this.props.category === AutocompleteCat.SHARING ? this.renderTabbedSuggestions() : this.renderSuggestions()}
104 const { label } = this.props;
105 return label && <InputLabel>{label}</InputLabel>;
110 disabled={this.props.disabled}
111 autoFocus={this.props.autofocus}
112 inputRef={this.inputRef}
113 value={this.props.value}
114 startAdornment={this.renderChips()}
115 onFocus={this.handleFocus}
116 onBlur={this.handleBlur}
117 onChange={this.props.onChange}
118 onKeyPress={this.handleKeyPress}
119 onKeyDown={this.handleNavigationKeyPress}
124 return <FormHelperText>{this.props.helperText}</FormHelperText>;
127 renderSuggestions() {
128 const { suggestions = [] } = this.props;
131 open={this.isSuggestionBoxOpen()}
132 anchorEl={this.inputRef.current}
133 key={suggestions.length}>
134 <Paper onMouseDown={this.preventBlur}>
135 <List dense style={{ width: this.getSuggestionsWidth() }}>
137 (suggestion, index) =>
141 onClick={this.handleSelect(suggestion)}
142 selected={index === this.state.selectedSuggestionIndex}>
143 {this.renderSuggestion(suggestion)}
152 renderTabbedSuggestions() {
153 const { suggestions = [], classes } = this.props;
155 const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
156 event.preventDefault();
157 this.setState({ selectedTab: newValue });
162 open={this.isSuggestionBoxOpen()}
163 anchorEl={this.inputRef.current}
164 key={suggestions.length}
165 style={{ width: this.getSuggestionsWidth()}}
167 <Paper onMouseDown={this.preventBlur}>
169 tabbedListContents={this.state.tabbedListContents}
170 renderListItem={this.renderSharingSuggestion}
171 injectedStyles={classes.tabbedListStyles}
172 selectedIndex={this.state.selectedSuggestionIndex}
173 selectedTab={this.state.selectedTab}
174 handleTabChange={handleTabChange}
175 handleSelect={this.handleSelect}
176 includeContentsLength={true}
183 isSuggestionBoxOpen() {
184 const { suggestions = [] } = this.props;
185 return this.state.suggestionsOpen && suggestions.length > 0;
188 handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
189 const { onFocus = noop } = this.props;
190 this.setState({ suggestionsOpen: true });
194 handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
196 const { onBlur = noop } = this.props;
197 this.setState({ suggestionsOpen: false });
202 handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
203 const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props;
204 const { selectedSuggestionIndex, selectedTab } = this.state;
205 if (event.key === 'Enter') {
206 if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
207 // prevent form submissions when selecting a suggestion
208 event.preventDefault();
209 if(this.props.category === AutocompleteCat.SHARING) {
210 onSelect(this.state.tabbedListContents[Object.keys(this.state.tabbedListContents)[selectedTab]][selectedSuggestionIndex]);
212 onSelect(suggestions[selectedSuggestionIndex]);
214 } else if (this.props.value.length > 0) {
220 handleNavigationKeyPress = (ev: React.KeyboardEvent<HTMLInputElement>) => {
221 if (ev.key === 'Tab' && this.isSuggestionBoxOpen()) {
223 // Cycle through tabs, or loop back to the first tab
224 this.setState({ selectedTab: ((this.state.selectedTab + 1) % Object.keys(this.state.tabbedListContents).length)} || 0)
226 if (ev.key === 'ArrowUp') {
227 this.updateSelectedSuggestionIndex(-1);
228 } else if (ev.key === 'ArrowDown') {
229 this.updateSelectedSuggestionIndex(1);
233 updateSelectedSuggestionIndex(value: -1 | 1) {
234 const { suggestions = [], category } = this.props;
235 const { tabbedListContents, selectedTab } = this.state;
236 const tabLabels = Object.keys(tabbedListContents);
237 this.setState(({ selectedSuggestionIndex }) => ({
238 selectedSuggestionIndex: (selectedSuggestionIndex + value) % (category === AutocompleteCat.SHARING
239 ? tabbedListContents[tabLabels[selectedTab]].length
240 : suggestions.length)
245 const { items, onDelete } = this.props;
248 * If input startAdornment prop is not undefined, input's label will stay above the input.
249 * If there is not items, we want the label to go back to placeholder position.
250 * That why we return without a value instead of returning a result of a _map_ which is an empty array.
252 if (items.length === 0) {
258 const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : '';
259 if (tooltip && tooltip.length) {
260 return <span key={index}>
261 <Tooltip title={tooltip}>
263 label={this.renderChipValue(item)}
265 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} />
268 return <span key={index}><Chip
269 label={this.renderChipValue(item)}
270 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} /></span>
276 renderChipValue(value: Value) {
277 const { renderChipValue } = this.props;
278 return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
281 preventBlur = (event: React.MouseEvent<HTMLElement>) => {
282 event.preventDefault();
285 handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
286 if (event.target !== this.inputRef.current) {
287 this.setState({ suggestionsOpen: false });
291 handleSelect = (suggestion: Suggestion) => {
293 const { onSelect = noop } = this.props;
294 const { current } = this.inputRef;
298 onSelect(suggestion);
302 renderSuggestion(suggestion: Suggestion) {
303 const { renderSuggestion } = this.props;
304 return renderSuggestion
305 ? renderSuggestion(suggestion)
306 : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
309 renderSharingSuggestion(suggestion: Suggestion) {
310 if (isGroup(suggestion)) {
311 return <ListItemText>
312 <Typography noWrap data-cy="sharing-suggestion">
313 {(suggestion as any).name}
316 return <ListItemText>
317 <Typography data-cy="sharing-suggestion">
318 {`${(suggestion as any).fullName} (${(suggestion as any).username})`}
323 getSuggestionsWidth() {
324 return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
328 type ChipClasses = 'root';
330 const chipStyles: StyleRulesCallback<ChipClasses> = theme => ({
332 marginRight: theme.spacing.unit / 4,
333 height: theme.spacing.unit * 3,
337 const Chip = withStyles(chipStyles)(MuiChip);
339 type PopperClasses = 'root';
341 const popperStyles: StyleRulesCallback<PopperClasses> = theme => ({
343 zIndex: theme.zIndex.modal,
347 const Popper = withStyles(popperStyles)(
348 ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
349 <MuiPopper {...props} className={classes.root} />
352 type InputClasses = 'root';
354 const inputStyles: StyleRulesCallback<InputClasses> = () => ({
365 const Input = withStyles(inputStyles)(MuiInput);
367 const Paper = withStyles({