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 };
88 export const Autocomplete = withStyles(autocompleteStyles)(
89 class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion> & WithStyles<AutocompleteClasses>, AutocompleteState> {
92 suggestionsOpen: false,
93 selectedSuggestionIndex: 0,
97 containerRef = React.createRef<HTMLDivElement>();
98 inputRef = React.createRef<HTMLInputElement>();
102 <RootRef rootRef={this.containerRef}>
103 <FormControl fullWidth error={this.props.error}>
106 {this.renderHelperText()}
107 {this.props.category === AutocompleteCat.SHARING ? this.renderTabbedSuggestions() : this.renderSuggestions()}
108 {/* {this.props.category === AutocompleteCat.SHARING ? this.renderSharingSuggestions() : this.renderSuggestions()} */}
115 const { label } = this.props;
116 return label && <InputLabel>{label}</InputLabel>;
121 disabled={this.props.disabled}
122 autoFocus={this.isInputAutoFocused()}
123 inputRef={this.inputRef}
124 value={this.props.value}
125 startAdornment={this.renderChips()}
126 onFocus={this.handleFocus}
127 onBlur={this.handleBlur}
128 onChange={this.props.onChange}
129 onKeyPress={this.handleKeyPress}
130 onKeyDown={this.handleNavigationKeyPress}
135 return <FormHelperText>{this.props.helperText}</FormHelperText>;
138 renderSuggestions() {
139 const { suggestions = [] } = this.props;
142 open={this.isSuggestionBoxOpen()}
143 anchorEl={this.inputRef.current}
144 key={suggestions.length}>
145 <Paper onMouseDown={this.preventBlur}>
146 <List dense style={{ width: this.getSuggestionsWidth() }}>
148 (suggestion, index) =>
152 onClick={this.handleSelect(suggestion)}
153 selected={index === this.state.selectedSuggestionIndex}>
154 {this.renderSuggestion(suggestion)}
163 renderSharingSuggestions() {
164 const { suggestions = [], classes } = this.props;
165 const users = sortByKey<Suggestion>(suggestions.filter(item => !isGroup(item)), 'fullName');
166 const groups = sortByKey<Suggestion>(suggestions.filter(item => isGroup(item)), 'name');
170 open={this.isSuggestionBoxOpen()}
171 anchorEl={this.inputRef.current}
172 key={suggestions.length}>
173 <Paper onMouseDown={this.preventBlur}>
174 <div className={classes.listSubHeader}>
175 Groups {<span className={classes.numFound}>{groups.length} {groups.length === 1 ? 'match' : 'matches'} found</span>}
177 <List dense className={classes.sharingList} style={{width: this.getSuggestionsWidth()}}>
179 (suggestion, index) =>
182 id={`groups-${index}`}
183 key={`groups-${index}`}
184 onClick={this.handleSelect(suggestion)}>
185 {this.renderSharingSuggestion(suggestion)}
189 <div className={classes.listSubHeader}>
190 Users {<span className={classes.numFound}>{users.length} {users.length === 1 ? 'match' : 'matches'} found</span>}
192 <List dense className={classes.sharingList} style={{width: this.getSuggestionsWidth()}}>
194 (suggestion, index) =>
197 id={`users-${index}`}
198 key={`users-${index}`}
199 onClick={this.handleSelect(suggestion)}>
200 {this.renderSharingSuggestion(suggestion)}
209 renderTabbedSuggestions() {
210 const { suggestions = [], classes } = this.props;
211 const users = sortByKey<Suggestion>(suggestions.filter(item => !isGroup(item)), 'fullName');
212 const groups = sortByKey<Suggestion>(suggestions.filter(item => isGroup(item)), 'name');
214 const parsedSugggestions = [{label: 'Groups', items: groups}, {label: 'Users', items: users}];
218 open={this.isSuggestionBoxOpen()}
219 anchorEl={this.inputRef.current}
220 key={suggestions.length}
221 style={{ width: this.getSuggestionsWidth()}}
223 <Paper onMouseDown={this.preventBlur}>
225 tabbedListContents={parsedSugggestions}
226 renderListItem={this.renderSharingSuggestion}
227 injectedStyles={classes.tabbedListStyles}
228 selectedIndex={this.state.selectedSuggestionIndex}
229 keypress={this.state.keypress}
236 isSuggestionBoxOpen() {
237 const { suggestions = [] } = this.props;
238 return this.state.suggestionsOpen && suggestions.length > 0;
241 isInputAutoFocused = () => this.props.autofocus || this.props.category === AutocompleteCat.SHARING
243 handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
244 const { onFocus = noop } = this.props;
245 this.setState({ suggestionsOpen: true });
249 handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
251 const { onBlur = noop } = this.props;
252 this.setState({ suggestionsOpen: false });
257 handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
258 const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props;
259 const { selectedSuggestionIndex } = this.state;
260 if (event.key === 'Enter') {
261 if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
262 // prevent form submissions when selecting a suggestion
263 event.preventDefault();
264 onSelect(suggestions[selectedSuggestionIndex]);
265 } else if (this.props.value.length > 0) {
271 handleNavigationKeyPress = (ev: React.KeyboardEvent<HTMLInputElement>) => {
272 this.setState({ keypress: { key: ev.key } });
273 if (ev.key === 'Tab' && this.isSuggestionBoxOpen()) {
276 if (ev.key === 'ArrowUp') {
277 this.updateSelectedSuggestionIndex(-1);
278 } else if (ev.key === 'ArrowDown') {
279 this.updateSelectedSuggestionIndex(1);
283 updateSelectedSuggestionIndex(value: -1 | 1) {
284 const { suggestions = [] } = this.props;
285 this.setState(({ selectedSuggestionIndex }) => ({
286 selectedSuggestionIndex: (selectedSuggestionIndex + value) % suggestions.length
291 const { items, onDelete } = this.props;
294 * If input startAdornment prop is not undefined, input's label will stay above the input.
295 * If there is not items, we want the label to go back to placeholder position.
296 * That why we return without a value instead of returning a result of a _map_ which is an empty array.
298 if (items.length === 0) {
304 const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : '';
305 if (tooltip && tooltip.length) {
306 return <span key={index}>
307 <Tooltip title={tooltip}>
309 label={this.renderChipValue(item)}
311 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} />
314 return <span key={index}><Chip
315 label={this.renderChipValue(item)}
316 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} /></span>
322 renderChipValue(value: Value) {
323 const { renderChipValue } = this.props;
324 return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
327 preventBlur = (event: React.MouseEvent<HTMLElement>) => {
328 event.preventDefault();
331 handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
332 if (event.target !== this.inputRef.current) {
333 this.setState({ suggestionsOpen: false });
337 handleSelect(suggestion: Suggestion) {
339 const { onSelect = noop } = this.props;
340 const { current } = this.inputRef;
344 onSelect(suggestion);
348 renderSuggestion(suggestion: Suggestion) {
349 const { renderSuggestion } = this.props;
350 return renderSuggestion
351 ? renderSuggestion(suggestion)
352 : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
355 renderSharingSuggestion(suggestion: Suggestion) {
356 if (isGroup(suggestion)) {
357 return <ListItemText>
358 <Typography noWrap data-cy="sharing-suggestion">
359 {(suggestion as any).name}
362 return <ListItemText>
363 <Typography data-cy="sharing-suggestion">
364 {`${(suggestion as any).fullName} (${(suggestion as any).username})`}
369 getSuggestionsWidth() {
370 return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
374 type ChipClasses = 'root';
376 const chipStyles: StyleRulesCallback<ChipClasses> = theme => ({
378 marginRight: theme.spacing.unit / 4,
379 height: theme.spacing.unit * 3,
383 const Chip = withStyles(chipStyles)(MuiChip);
385 type PopperClasses = 'root';
387 const popperStyles: StyleRulesCallback<PopperClasses> = theme => ({
389 zIndex: theme.zIndex.modal,
393 const Popper = withStyles(popperStyles)(
394 ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
395 <MuiPopper {...props} className={classes.root} />
398 type InputClasses = 'root';
400 const inputStyles: StyleRulesCallback<InputClasses> = () => ({
411 const Input = withStyles(inputStyles)(MuiInput);
413 const Paper = withStyles({