1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
5 import React from 'react';
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';
29 export interface AutocompleteProps<Item, Suggestion> {
34 suggestions?: Suggestion[];
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;
51 type AutocompleteClasses = 'tabbedListStyles';
53 const autocompleteStyles: CustomStyleRulesCallback<AutocompleteClasses> = theme => ({
59 export enum AutocompleteCat {
63 export interface AutocompleteState {
64 suggestionsOpen: boolean;
66 selectedSuggestionIndex: number;
67 tabbedListContents: Record<string, any[]>;
70 export const Autocomplete = withStyles(autocompleteStyles)(
71 class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion> & WithStyles<AutocompleteClasses>, AutocompleteState> {
74 suggestionsOpen: false,
76 selectedSuggestionIndex: 0,
77 tabbedListContents: {},
80 componentDidUpdate(prevProps: AutocompleteProps<Value, Suggestion>, prevState: AutocompleteState) {
81 const { suggestions = [], category } = this.props;
82 if( prevProps.suggestions?.length === 0 && suggestions.length > 0) {
83 this.setState({ selectedSuggestionIndex: 0, selectedTab: 0 });
85 if (category === AutocompleteCat.SHARING) {
86 if (Object.keys(this.state.tabbedListContents).length === 0) {
87 this.setState({ tabbedListContents: { groups: [], users: [] } });
89 if (prevProps.suggestions !== suggestions) {
90 const users = sortByKey<Suggestion>(suggestions.filter(item => !isGroup(item)), 'fullName');
91 const groups = sortByKey<Suggestion>(suggestions.filter(item => isGroup(item)), 'name');
92 this.setState({ tabbedListContents: { groups: groups, users: users } });
94 if (prevState.selectedTab !== this.state.selectedTab) {
95 this.setState({ selectedSuggestionIndex: 0 });
100 containerRef = React.createRef<HTMLDivElement>();
101 inputRef = React.createRef<HTMLInputElement>();
104 return <div ref={this.containerRef}>
105 <FormControl variant="standard" fullWidth error={this.props.error}>
108 {this.renderHelperText()}
109 {this.props.category === AutocompleteCat.SHARING ? this.renderTabbedSuggestions() : this.renderSuggestions()}
115 const { label } = this.props;
116 return label && <InputLabel>{label}</InputLabel>;
121 disabled={this.props.disabled}
122 autoFocus={this.props.autofocus}
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 renderTabbedSuggestions() {
164 const { suggestions = [], classes } = this.props;
166 const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
167 event.preventDefault();
168 this.setState({ selectedTab: newValue });
173 open={this.state.suggestionsOpen}
174 anchorEl={this.inputRef.current}
175 key={suggestions.length}
176 style={{ width: this.getSuggestionsWidth()}}
178 <Paper onMouseDown={this.preventBlur}>
180 tabbedListContents={this.state.tabbedListContents}
181 renderListItem={this.renderSharingSuggestion}
182 injectedStyles={classes.tabbedListStyles}
183 selectedIndex={this.state.selectedSuggestionIndex}
184 selectedTab={this.state.selectedTab}
185 handleTabChange={handleTabChange}
186 handleSelect={this.handleSelect}
187 includeContentsLength={true}
188 isWorking={this.props.isWorking}
195 isSuggestionBoxOpen() {
196 const { suggestions = [] } = this.props;
197 return this.state.suggestionsOpen && suggestions.length > 0;
200 handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
201 const { onFocus = noop } = this.props;
202 this.setState({ suggestionsOpen: true });
206 handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
208 const { onBlur = noop } = this.props;
209 this.setState({ suggestionsOpen: false });
214 handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
215 const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props;
216 const { selectedSuggestionIndex, selectedTab } = this.state;
217 if (event.key === 'Enter') {
218 if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
219 // prevent form submissions when selecting a suggestion
220 event.preventDefault();
221 if(this.props.category === AutocompleteCat.SHARING) {
222 onSelect(this.state.tabbedListContents[Object.keys(this.state.tabbedListContents)[selectedTab]][selectedSuggestionIndex]);
224 onSelect(suggestions[selectedSuggestionIndex]);
226 } else if (this.props.value.length > 0) {
232 handleNavigationKeyPress = (ev: React.KeyboardEvent<HTMLInputElement>) => {
233 if (ev.key === 'Tab' && this.isSuggestionBoxOpen()) {
235 // Cycle through tabs, or loop back to the first tab
236 this.setState({ selectedTab: ((this.state.selectedTab + 1) % Object.keys(this.state.tabbedListContents).length)} || 0)
238 if (ev.key === 'ArrowUp') {
239 this.updateSelectedSuggestionIndex(-1);
240 } else if (ev.key === 'ArrowDown') {
241 this.updateSelectedSuggestionIndex(1);
245 updateSelectedSuggestionIndex(value: -1 | 1) {
246 const { suggestions = [], category } = this.props;
247 const { tabbedListContents, selectedTab } = this.state;
248 const tabLabels = Object.keys(tabbedListContents);
249 this.setState(({ selectedSuggestionIndex }) => ({
250 selectedSuggestionIndex: (selectedSuggestionIndex + value) % (category === AutocompleteCat.SHARING
251 ? tabbedListContents[tabLabels[selectedTab]].length
252 : suggestions.length)
257 const { items, onDelete } = this.props;
260 * If input startAdornment prop is not undefined, input's label will stay above the input.
261 * If there is not items, we want the label to go back to placeholder position.
262 * That why we return without a value instead of returning a result of a _map_ which is an empty array.
264 if (items.length === 0) {
270 const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : '';
271 if (tooltip && tooltip.length) {
272 return <span key={index}>
273 <Tooltip title={tooltip}>
275 label={this.renderChipValue(item)}
277 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} />
280 return <span key={index}><Chip
281 label={this.renderChipValue(item)}
282 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} /></span>
288 renderChipValue(value: Value) {
289 const { renderChipValue } = this.props;
290 return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
293 preventBlur = (event: React.MouseEvent<HTMLElement>) => {
294 event.preventDefault();
297 handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
298 if (event.target !== this.inputRef.current) {
299 this.setState({ suggestionsOpen: false });
303 handleSelect = (suggestion: Suggestion) => {
305 const { onSelect = noop } = this.props;
306 const { current } = this.inputRef;
310 onSelect(suggestion);
314 renderSuggestion(suggestion: Suggestion) {
315 const { renderSuggestion } = this.props;
316 return renderSuggestion
317 ? renderSuggestion(suggestion)
318 : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
321 renderSharingSuggestion(suggestion: Suggestion) {
322 if (isGroup(suggestion)) {
323 return <ListItemText>
324 <Typography noWrap data-cy="sharing-suggestion">
325 {(suggestion as any).name}
328 return <ListItemText>
329 <Typography data-cy="sharing-suggestion">
330 {`${(suggestion as any).fullName} (${(suggestion as any).username})`}
335 getSuggestionsWidth() {
336 return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
340 type ChipClasses = 'root';
342 const chipStyles: CustomStyleRulesCallback<ChipClasses> = theme => ({
344 marginRight: theme.spacing(0.25),
345 height: theme.spacing(3),
349 const Chip = withStyles(chipStyles)(MuiChip);
351 type PopperClasses = 'root';
353 const popperStyles: CustomStyleRulesCallback<ChipClasses> = theme => ({
355 zIndex: theme.zIndex.modal,
359 const Popper = withStyles(popperStyles)(
360 ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
361 <MuiPopper {...props} className={classes.root} />
364 type InputClasses = 'root';
366 const inputStyles: CustomStyleRulesCallback<InputClasses> = () => ({
377 const Input = withStyles(inputStyles)(MuiInput);
379 const Paper = withStyles({