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, Tabs, Tab
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 = 'sharingList' | 'emptyList' | 'listSubHeader' | 'numFound' | 'tabbedListStyles';
43 const autocompleteStyles: StyleRulesCallback<AutocompleteClasses> = theme => ({
47 scrollbarColor: 'rgba(0, 0, 0, 0.3) rgba(0, 0, 0, 0)',
48 '&::-webkit-scrollbar': {
51 '&::-webkit-scrollbar-thumb': {
52 backgroundColor: 'rgba(0, 0, 0, 0.3)',
55 '&::-webkit-scrollbar-track': {
56 backgroundColor: 'rgba(0, 0, 0, 0)',
66 alignItems: 'flex-end',
67 justifyContent: 'space-between',
78 export enum AutocompleteCat {
82 export interface AutocompleteState {
83 suggestionsOpen: boolean;
84 selectedSuggestionIndex: number;
85 keypress: { key: string };
86 tabbedListContents: Record<string, any[]>;
89 export const Autocomplete = withStyles(autocompleteStyles)(
90 class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion> & WithStyles<AutocompleteClasses>, AutocompleteState> {
93 suggestionsOpen: false,
94 selectedSuggestionIndex: 0,
95 keypress: { key: '' },
96 tabbedListContents: {},
99 componentDidUpdate(prevProps: AutocompleteProps<Value, Suggestion>, prevState: AutocompleteState) {
100 const { suggestions = [], category } = this.props;
101 if( prevProps.suggestions?.length === 0 && suggestions.length > 0) {
102 this.setState({ selectedSuggestionIndex: 0 });
104 if (category === AutocompleteCat.SHARING && prevProps.suggestions !== suggestions) {
105 const users = sortByKey<Suggestion>(suggestions.filter(item => !isGroup(item)), 'fullName');
106 const groups = sortByKey<Suggestion>(suggestions.filter(item => isGroup(item)), 'name');
107 this.setState({ tabbedListContents: { Groups: groups, Users: users } });
111 containerRef = React.createRef<HTMLDivElement>();
112 inputRef = React.createRef<HTMLInputElement>();
116 <RootRef rootRef={this.containerRef}>
117 <FormControl fullWidth error={this.props.error}>
120 {this.renderHelperText()}
121 {this.props.category === AutocompleteCat.SHARING ? this.renderTabbedSuggestions() : this.renderSuggestions()}
122 {/* {this.props.category === AutocompleteCat.SHARING ? this.renderSharingSuggestions() : this.renderSuggestions()} */}
129 const { label } = this.props;
130 return label && <InputLabel>{label}</InputLabel>;
135 disabled={this.props.disabled}
136 autoFocus={this.isInputAutoFocused()}
137 inputRef={this.inputRef}
138 value={this.props.value}
139 startAdornment={this.renderChips()}
140 onFocus={this.handleFocus}
141 onBlur={this.handleBlur}
142 onChange={this.props.onChange}
143 onKeyPress={this.handleKeyPress}
144 onKeyDown={this.handleNavigationKeyPress}
149 return <FormHelperText>{this.props.helperText}</FormHelperText>;
152 renderSuggestions() {
153 const { suggestions = [] } = this.props;
156 open={this.isSuggestionBoxOpen()}
157 anchorEl={this.inputRef.current}
158 key={suggestions.length}>
159 <Paper onMouseDown={this.preventBlur}>
160 <List dense style={{ width: this.getSuggestionsWidth() }}>
162 (suggestion, index) =>
166 onClick={this.handleSelect(suggestion)}
167 selected={index === this.state.selectedSuggestionIndex}>
168 {this.renderSuggestion(suggestion)}
177 renderSharingSuggestions() {
178 const { suggestions = [], classes } = this.props;
179 const users = sortByKey<Suggestion>(suggestions.filter(item => !isGroup(item)), 'fullName');
180 const groups = sortByKey<Suggestion>(suggestions.filter(item => isGroup(item)), 'name');
184 open={this.isSuggestionBoxOpen()}
185 anchorEl={this.inputRef.current}
186 key={suggestions.length}>
187 <Paper onMouseDown={this.preventBlur}>
188 <div className={classes.listSubHeader}>
189 Groups {<span className={classes.numFound}>{groups.length} {groups.length === 1 ? 'match' : 'matches'} found</span>}
191 <List dense className={classes.sharingList} style={{width: this.getSuggestionsWidth()}}>
193 (suggestion, index) =>
196 id={`groups-${index}`}
197 key={`groups-${index}`}
198 onClick={this.handleSelect(suggestion)}>
199 {this.renderSharingSuggestion(suggestion)}
203 <div className={classes.listSubHeader}>
204 Users {<span className={classes.numFound}>{users.length} {users.length === 1 ? 'match' : 'matches'} found</span>}
206 <List dense className={classes.sharingList} style={{width: this.getSuggestionsWidth()}}>
208 (suggestion, index) =>
211 id={`users-${index}`}
212 key={`users-${index}`}
213 onClick={this.handleSelect(suggestion)}>
214 {this.renderSharingSuggestion(suggestion)}
223 renderTabbedSuggestions() {
224 const { suggestions = [], classes } = this.props;
228 open={this.isSuggestionBoxOpen()}
229 anchorEl={this.inputRef.current}
230 key={suggestions.length}
231 style={{ width: this.getSuggestionsWidth()}}
233 <Paper onMouseDown={this.preventBlur}>
235 tabbedListContents={this.state.tabbedListContents}
236 renderListItem={this.renderSharingSuggestion}
237 injectedStyles={classes.tabbedListStyles}
238 selectedIndex={this.state.selectedSuggestionIndex}
239 keypress={this.state.keypress}
246 isSuggestionBoxOpen() {
247 const { suggestions = [] } = this.props;
248 return this.state.suggestionsOpen && suggestions.length > 0;
251 isInputAutoFocused = () => this.props.autofocus || this.props.category === AutocompleteCat.SHARING
253 handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
254 const { onFocus = noop } = this.props;
255 this.setState({ suggestionsOpen: true });
259 handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
261 const { onBlur = noop } = this.props;
262 this.setState({ suggestionsOpen: false });
267 handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
268 const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props;
269 const { selectedSuggestionIndex } = this.state;
270 if (event.key === 'Enter') {
271 if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
272 // prevent form submissions when selecting a suggestion
273 event.preventDefault();
274 onSelect(suggestions[selectedSuggestionIndex]);
275 } else if (this.props.value.length > 0) {
281 handleNavigationKeyPress = (ev: React.KeyboardEvent<HTMLInputElement>) => {
282 this.setState({ keypress: { key: ev.key } });
283 if (ev.key === 'Tab' && this.isSuggestionBoxOpen()) {
286 if (ev.key === 'ArrowUp') {
287 this.updateSelectedSuggestionIndex(-1);
288 } else if (ev.key === 'ArrowDown') {
289 this.updateSelectedSuggestionIndex(1);
293 updateSelectedSuggestionIndex(value: -1 | 1) {
294 const { suggestions = [] } = this.props;
295 this.setState(({ selectedSuggestionIndex }) => ({
296 selectedSuggestionIndex: (selectedSuggestionIndex + value) % suggestions.length
301 const { items, onDelete } = this.props;
304 * If input startAdornment prop is not undefined, input's label will stay above the input.
305 * If there is not items, we want the label to go back to placeholder position.
306 * That why we return without a value instead of returning a result of a _map_ which is an empty array.
308 if (items.length === 0) {
314 const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : '';
315 if (tooltip && tooltip.length) {
316 return <span key={index}>
317 <Tooltip title={tooltip}>
319 label={this.renderChipValue(item)}
321 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} />
324 return <span key={index}><Chip
325 label={this.renderChipValue(item)}
326 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} /></span>
332 renderChipValue(value: Value) {
333 const { renderChipValue } = this.props;
334 return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
337 preventBlur = (event: React.MouseEvent<HTMLElement>) => {
338 event.preventDefault();
341 handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
342 if (event.target !== this.inputRef.current) {
343 this.setState({ suggestionsOpen: false });
347 handleSelect(suggestion: Suggestion) {
349 const { onSelect = noop } = this.props;
350 const { current } = this.inputRef;
354 onSelect(suggestion);
358 renderSuggestion(suggestion: Suggestion) {
359 const { renderSuggestion } = this.props;
360 return renderSuggestion
361 ? renderSuggestion(suggestion)
362 : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
365 renderSharingSuggestion(suggestion: Suggestion) {
366 if (isGroup(suggestion)) {
367 return <ListItemText>
368 <Typography noWrap data-cy="sharing-suggestion">
369 {(suggestion as any).name}
372 return <ListItemText>
373 <Typography data-cy="sharing-suggestion">
374 {`${(suggestion as any).fullName} (${(suggestion as any).username})`}
379 getSuggestionsWidth() {
380 return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
384 type ChipClasses = 'root';
386 const chipStyles: StyleRulesCallback<ChipClasses> = theme => ({
388 marginRight: theme.spacing.unit / 4,
389 height: theme.spacing.unit * 3,
393 const Chip = withStyles(chipStyles)(MuiChip);
395 type PopperClasses = 'root';
397 const popperStyles: StyleRulesCallback<PopperClasses> = theme => ({
399 zIndex: theme.zIndex.modal,
403 const Popper = withStyles(popperStyles)(
404 ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
405 <MuiPopper {...props} className={classes.root} />
408 type InputClasses = 'root';
410 const inputStyles: StyleRulesCallback<InputClasses> = () => ({
421 const Input = withStyles(inputStyles)(MuiInput);
423 const Paper = withStyles({