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 = 'listItemStyle';
53 const autocompleteStyles: CustomStyleRulesCallback<AutocompleteClasses> = theme => ({
57 textOverflow: 'ellipsis',
61 export enum AutocompleteCat {
65 export interface AutocompleteState {
66 suggestionsOpen: boolean;
68 selectedSuggestionIndex: number;
69 tabbedListContents: Record<string, any[]>;
72 export const Autocomplete = withStyles(autocompleteStyles)(
73 class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion> & WithStyles<AutocompleteClasses>, AutocompleteState> {
76 suggestionsOpen: false,
78 selectedSuggestionIndex: 0,
79 tabbedListContents: {},
82 componentDidUpdate(prevProps: AutocompleteProps<Value, Suggestion>, prevState: AutocompleteState) {
83 const { suggestions = [], category } = this.props;
84 if( prevProps.suggestions?.length === 0 && suggestions.length > 0) {
85 this.setState({ selectedSuggestionIndex: 0, selectedTab: 0 });
87 if (category === AutocompleteCat.SHARING) {
88 if (Object.keys(this.state.tabbedListContents).length === 0) {
89 this.setState({ tabbedListContents: { groups: [], users: [] } });
91 if (prevProps.suggestions !== suggestions) {
92 const users = sortByKey<Suggestion>(suggestions.filter(item => !isGroup(item)), 'fullName');
93 const groups = sortByKey<Suggestion>(suggestions.filter(item => isGroup(item)), 'name');
94 this.setState({ tabbedListContents: { groups: groups, users: users } });
96 if (prevState.selectedTab !== this.state.selectedTab) {
97 this.setState({ selectedSuggestionIndex: 0 });
102 containerRef = React.createRef<HTMLDivElement>();
103 inputRef = React.createRef<HTMLInputElement>();
106 return <div ref={this.containerRef}>
107 <FormControl variant="standard" fullWidth error={this.props.error}>
110 {this.renderHelperText()}
111 {this.props.category === AutocompleteCat.SHARING ? this.renderTabbedSuggestions() : this.renderSuggestions()}
117 const { label } = this.props;
118 return label && <InputLabel>{label}</InputLabel>;
123 disabled={this.props.disabled}
124 autoFocus={this.props.autofocus}
125 inputRef={this.inputRef}
126 value={this.props.value}
127 startAdornment={this.renderChips()}
128 onFocus={this.handleFocus}
129 onBlur={this.handleBlur}
130 onChange={this.props.onChange}
131 onKeyPress={this.handleKeyPress}
132 onKeyDown={this.handleNavigationKeyPress}
137 return <FormHelperText>{this.props.helperText}</FormHelperText>;
140 renderSuggestions() {
141 const { suggestions = [] } = this.props;
144 open={this.isSuggestionBoxOpen()}
145 anchorEl={this.inputRef.current}
146 key={suggestions.length}>
147 <Paper onMouseDown={this.preventBlur}>
148 <List dense style={{ width: this.getSuggestionsWidth() }}>
150 (suggestion, index) =>
154 onClick={this.handleSelect(suggestion)}
155 selected={index === this.state.selectedSuggestionIndex}>
156 {this.renderSuggestion(suggestion)}
165 renderTabbedSuggestions() {
166 const { suggestions = [], classes } = this.props;
170 open={this.state.suggestionsOpen}
171 anchorEl={this.containerRef.current || this.inputRef.current}
172 key={suggestions.length}
173 style={{ width: this.getSuggestionsWidth()}}
175 <Paper onMouseDown={this.preventBlur}>
177 tabbedListContents={this.state.tabbedListContents}
178 renderListItem={this.renderSharingSuggestion}
179 selectedIndex={this.state.selectedSuggestionIndex}
180 selectedTab={this.state.selectedTab}
181 handleTabChange={this.handleTabChange}
182 handleSelect={this.handleSelect}
183 includeContentsLength={true}
184 isWorking={this.props.isWorking}
191 isSuggestionBoxOpen() {
192 const { suggestions = [] } = this.props;
193 return this.state.suggestionsOpen && suggestions.length > 0;
196 handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
197 const { onFocus = noop } = this.props;
198 this.setState({ suggestionsOpen: true });
202 handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
204 const { onBlur = noop } = this.props;
205 this.setState({ suggestionsOpen: false });
210 handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
211 event.preventDefault();
212 this.setState({ selectedTab: newValue });
215 handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
216 const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props;
217 const { selectedSuggestionIndex, selectedTab } = this.state;
218 if (event.key === 'Enter') {
219 if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
220 // prevent form submissions when selecting a suggestion
221 event.preventDefault();
222 if(this.props.category === AutocompleteCat.SHARING) {
223 onSelect(this.state.tabbedListContents[Object.keys(this.state.tabbedListContents)[selectedTab]][selectedSuggestionIndex]);
225 onSelect(suggestions[selectedSuggestionIndex]);
227 } else if (this.props.value.length > 0) {
233 handleNavigationKeyPress = (ev: React.KeyboardEvent<HTMLInputElement>) => {
234 if (ev.key === 'Tab' && this.isSuggestionBoxOpen()) {
236 // Cycle through tabs, or loop back to the first tab
237 this.setState({ selectedTab: ((this.state.selectedTab + 1) % Object.keys(this.state.tabbedListContents).length)} || 0)
239 if (ev.key === 'ArrowUp') {
241 this.updateSelectedSuggestionIndex(-1);
242 } else if (ev.key === 'ArrowDown') {
244 this.updateSelectedSuggestionIndex(1);
248 updateSelectedSuggestionIndex(value: -1 | 1) {
249 const { suggestions = [], category } = this.props;
250 const { tabbedListContents, selectedTab, selectedSuggestionIndex } = this.state;
251 const tabLabels = Object.keys(tabbedListContents);
252 const currentList = category === AutocompleteCat.SHARING ? tabbedListContents[tabLabels[selectedTab]] : suggestions;
253 if(selectedSuggestionIndex <= 0 && value === -1) {
254 this.setState({selectedSuggestionIndex: currentList.length - 1});
256 this.setState(({ selectedSuggestionIndex }) => ({
257 selectedSuggestionIndex: (selectedSuggestionIndex + value) % currentList.length,
263 const { items, onDelete } = this.props;
266 * If input startAdornment prop is not undefined, input's label will stay above the input.
267 * If there is not items, we want the label to go back to placeholder position.
268 * That why we return without a value instead of returning a result of a _map_ which is an empty array.
270 if (items.length === 0) {
276 const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : '';
277 if (tooltip && tooltip.length) {
278 return <span key={index}>
279 <Tooltip title={tooltip}>
281 label={this.renderChipValue(item)}
283 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} />
286 return <span key={index}><Chip
287 label={this.renderChipValue(item)}
288 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} /></span>
294 renderChipValue(value: Value) {
295 const { renderChipValue } = this.props;
296 return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
299 preventBlur = (event: React.MouseEvent<HTMLElement>) => {
300 event.preventDefault();
303 handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
304 if (event.target !== this.inputRef.current) {
305 this.setState({ suggestionsOpen: false });
309 handleSelect = (suggestion: Suggestion) => {
311 const { onSelect = noop } = this.props;
312 const { current } = this.inputRef;
316 onSelect(suggestion);
320 renderSuggestion(suggestion: Suggestion) {
321 const { renderSuggestion } = this.props;
322 return renderSuggestion
323 ? renderSuggestion(suggestion)
324 : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
327 renderSharingSuggestion = (suggestion: Suggestion) => {
328 return <ListItemText>
329 <Typography className={this.props.classes.listItemStyle} data-cy="sharing-suggestion">
330 { isGroup(suggestion) ? (suggestion as any).name : `${(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({