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 classNames from 'classnames';
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 = 'sharingList' | 'emptyList';
43 const autocompleteStyles: StyleRulesCallback<AutocompleteClasses> = theme => ({
54 export enum AutocompleteCat {
58 export interface AutocompleteState {
59 suggestionsOpen: boolean;
60 selectedSuggestionIndex: number;
63 export const Autocomplete = withStyles(autocompleteStyles)(
64 class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion> & WithStyles<AutocompleteClasses>, AutocompleteState> {
67 suggestionsOpen: false,
68 selectedSuggestionIndex: 0,
71 containerRef = React.createRef<HTMLDivElement>();
72 inputRef = React.createRef<HTMLInputElement>();
76 <RootRef rootRef={this.containerRef}>
77 <FormControl fullWidth error={this.props.error}>
80 {this.renderHelperText()}
81 {this.props.category === AutocompleteCat.SHARING ? this.renderSharingSuggestions() : this.renderSuggestions()}
88 const { label } = this.props;
89 return label && <InputLabel>{label}</InputLabel>;
94 disabled={this.props.disabled}
95 autoFocus={this.props.autofocus}
96 inputRef={this.inputRef}
97 value={this.props.value}
98 startAdornment={this.renderChips()}
99 onFocus={this.handleFocus}
100 onBlur={this.handleBlur}
101 onChange={this.props.onChange}
102 onKeyPress={this.handleKeyPress}
103 onKeyDown={this.handleNavigationKeyPress}
108 return <FormHelperText>{this.props.helperText}</FormHelperText>;
111 renderSuggestions() {
112 const { suggestions = [] } = this.props;
115 open={this.isSuggestionBoxOpen()}
116 anchorEl={this.inputRef.current}
117 key={suggestions.length}>
118 <Paper onMouseDown={this.preventBlur}>
119 <List dense style={{ width: this.getSuggestionsWidth() }}>
121 (suggestion, index) =>
125 onClick={this.handleSelect(suggestion)}
126 selected={index === this.state.selectedSuggestionIndex}>
127 {this.renderSuggestion(suggestion)}
136 renderSharingSuggestions() {
137 const { suggestions = [], classes } = this.props;
138 const users = sortByKey<Suggestion>(suggestions.filter(item => !isGroup(item)), 'fullName');
139 const groups = sortByKey<Suggestion>(suggestions.filter(item => isGroup(item)), 'name');
143 open={this.isSuggestionBoxOpen()}
144 anchorEl={this.inputRef.current}
145 key={suggestions.length}>
146 <Paper onMouseDown={this.preventBlur}>
149 <List dense className={classes.sharingList} style={{width: this.getSuggestionsWidth()}}>
151 (suggestion, index) =>
154 id={`groups-${index}`}
155 key={`groups-${index}`}
156 onClick={this.handleSelect(suggestion)}>
157 {this.renderSuggestion(suggestion)}
160 </List> : <Typography variant="caption" className={classes.emptyList}>no groups found</Typography>}
163 <List dense className={classes.sharingList} style={{width: this.getSuggestionsWidth()}}>
165 (suggestion, index) =>
168 id={`users-${index}`}
169 key={`users-${index}`}
170 onClick={this.handleSelect(suggestion)}>
171 {this.renderSuggestion(suggestion)}
174 </List> : <Typography variant="caption" className={classes.emptyList}>no users found</Typography>}
180 isSuggestionBoxOpen() {
181 const { suggestions = [] } = this.props;
182 return this.state.suggestionsOpen && suggestions.length > 0;
185 handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
186 const { onFocus = noop } = this.props;
187 this.setState({ suggestionsOpen: true });
191 handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
193 const { onBlur = noop } = this.props;
194 this.setState({ suggestionsOpen: false });
199 handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
200 const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props;
201 const { selectedSuggestionIndex } = this.state;
202 if (event.key === 'Enter') {
203 if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
204 // prevent form submissions when selecting a suggestion
205 event.preventDefault();
206 onSelect(suggestions[selectedSuggestionIndex]);
207 } else if (this.props.value.length > 0) {
213 handleNavigationKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
214 if (key === 'ArrowUp') {
215 this.updateSelectedSuggestionIndex(-1);
216 } else if (key === 'ArrowDown') {
217 this.updateSelectedSuggestionIndex(1);
221 updateSelectedSuggestionIndex(value: -1 | 1) {
222 const { suggestions = [] } = this.props;
223 this.setState(({ selectedSuggestionIndex }) => ({
224 selectedSuggestionIndex: (selectedSuggestionIndex + value) % suggestions.length
229 const { items, onDelete } = this.props;
232 * If input startAdornment prop is not undefined, input's label will stay above the input.
233 * If there is not items, we want the label to go back to placeholder position.
234 * That why we return without a value instead of returning a result of a _map_ which is an empty array.
236 if (items.length === 0) {
242 const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : '';
243 if (tooltip && tooltip.length) {
244 return <span key={index}>
245 <Tooltip title={tooltip}>
247 label={this.renderChipValue(item)}
249 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} />
252 return <span key={index}><Chip
253 label={this.renderChipValue(item)}
254 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} /></span>
260 renderChipValue(value: Value) {
261 const { renderChipValue } = this.props;
262 return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
265 preventBlur = (event: React.MouseEvent<HTMLElement>) => {
266 event.preventDefault();
269 handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
270 if (event.target !== this.inputRef.current) {
271 this.setState({ suggestionsOpen: false });
275 handleSelect(suggestion: Suggestion) {
277 const { onSelect = noop } = this.props;
278 const { current } = this.inputRef;
282 onSelect(suggestion);
286 renderSuggestion(suggestion: Suggestion) {
287 const { renderSuggestion } = this.props;
288 return renderSuggestion
289 ? renderSuggestion(suggestion)
290 : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
293 getSuggestionsWidth() {
294 return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
298 type ChipClasses = 'root';
300 const chipStyles: StyleRulesCallback<ChipClasses> = theme => ({
302 marginRight: theme.spacing.unit / 4,
303 height: theme.spacing.unit * 3,
307 const Chip = withStyles(chipStyles)(MuiChip);
309 type PopperClasses = 'root';
311 const popperStyles: StyleRulesCallback<PopperClasses> = theme => ({
313 zIndex: theme.zIndex.modal,
317 const Popper = withStyles(popperStyles)(
318 ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
319 <MuiPopper {...props} className={classes.root} />
322 type InputClasses = 'root';
324 const inputStyles: StyleRulesCallback<InputClasses> = () => ({
335 const Input = withStyles(inputStyles)(MuiInput);
337 const Paper = withStyles({