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;
52 type AutocompleteClasses = 'listItemStyle';
54 const autocompleteStyles: CustomStyleRulesCallback<AutocompleteClasses> = theme => ({
58 textOverflow: 'ellipsis',
62 export enum AutocompleteCat {
66 export interface AutocompleteState {
67 suggestionsOpen: boolean;
69 selectedSuggestionIndex: number;
70 tabbedListContents: Record<string, any[]>;
73 export const Autocomplete = withStyles(autocompleteStyles)(
74 class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion> & WithStyles<AutocompleteClasses>, AutocompleteState> {
77 suggestionsOpen: false,
79 selectedSuggestionIndex: 0,
80 tabbedListContents: {},
83 componentDidUpdate(prevProps: AutocompleteProps<Value, Suggestion>, prevState: AutocompleteState) {
84 const { suggestions = [], category } = this.props;
85 if( prevProps.suggestions?.length === 0 && suggestions.length > 0) {
86 this.setState({ selectedSuggestionIndex: 0, selectedTab: 0 });
88 if (category === AutocompleteCat.SHARING) {
89 if (Object.keys(this.state.tabbedListContents).length === 0) {
90 this.setState({ tabbedListContents: { groups: [], users: [] } });
92 if (prevProps.suggestions !== suggestions) {
93 const users = sortByKey<Suggestion>(suggestions.filter(item => !isGroup(item)), 'fullName');
94 const groups = sortByKey<Suggestion>(suggestions.filter(item => isGroup(item)), 'name');
95 this.setState({ tabbedListContents: { groups: groups, users: users } });
97 if (prevState.selectedTab !== this.state.selectedTab) {
98 this.setState({ selectedSuggestionIndex: 0 });
103 containerRef = React.createRef<HTMLDivElement>();
104 inputRef = React.createRef<HTMLInputElement>();
107 return <div ref={this.containerRef}>
108 <FormControl variant="standard" fullWidth error={this.props.error}>
111 {this.renderHelperText()}
112 {this.props.category === AutocompleteCat.SHARING ? this.renderTabbedSuggestions() : this.renderSuggestions()}
118 const { label } = this.props;
119 return label && <InputLabel>{label}</InputLabel>;
124 disabled={this.props.disabled}
125 autoFocus={this.props.autofocus}
126 inputRef={this.inputRef}
127 value={this.props.value}
128 startAdornment={this.renderChips()}
129 onFocus={this.handleFocus}
130 onBlur={this.handleBlur}
131 onChange={this.props.onChange}
132 onKeyPress={this.handleKeyPress}
133 onKeyDown={this.handleNavigationKeyPress}
138 return <FormHelperText>{this.props.helperText}</FormHelperText>;
141 renderSuggestions() {
142 const { suggestions = [] } = this.props;
145 open={this.isSuggestionBoxOpen()}
146 anchorEl={this.inputRef.current}
147 key={suggestions.length}>
148 <Paper onMouseDown={this.preventBlur}>
149 <List dense style={{ width: this.getSuggestionsWidth() }}>
151 (suggestion, index) =>
155 onClick={this.handleSelect(suggestion)}
156 selected={index === this.state.selectedSuggestionIndex}>
157 {this.renderSuggestion(suggestion)}
166 renderTabbedSuggestions() {
167 const { suggestions = [], classes } = this.props;
171 open={this.state.suggestionsOpen}
172 anchorEl={this.containerRef.current || this.inputRef.current}
173 key={suggestions.length}
174 style={{ width: this.getSuggestionsWidth()}}
176 <Paper onMouseDown={this.preventBlur}>
178 tabbedListContents={this.state.tabbedListContents}
179 renderListItem={this.renderSharingSuggestion}
180 selectedIndex={this.state.selectedSuggestionIndex}
181 selectedTab={this.state.selectedTab}
182 handleTabChange={this.handleTabChange}
183 handleSelect={this.handleSelect}
184 includeContentsLength={true}
185 isWorking={this.props.isWorking}
186 maxLength={this.props.maxLength}
193 isSuggestionBoxOpen() {
194 const { suggestions = [] } = this.props;
195 return this.state.suggestionsOpen && suggestions.length > 0;
198 handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
199 const { onFocus = noop } = this.props;
200 this.setState({ suggestionsOpen: true });
204 handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
206 const { onBlur = noop } = this.props;
207 this.setState({ suggestionsOpen: false });
212 handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
213 event.preventDefault();
214 this.setState({ selectedTab: newValue });
217 handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
218 const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props;
219 const { selectedSuggestionIndex, selectedTab } = this.state;
220 if (event.key === 'Enter') {
221 if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
222 // prevent form submissions when selecting a suggestion
223 event.preventDefault();
224 if(this.props.category === AutocompleteCat.SHARING) {
225 onSelect(this.state.tabbedListContents[Object.keys(this.state.tabbedListContents)[selectedTab]][selectedSuggestionIndex]);
227 onSelect(suggestions[selectedSuggestionIndex]);
229 } else if (this.props.value.length > 0) {
235 handleNavigationKeyPress = (ev: React.KeyboardEvent<HTMLInputElement>) => {
236 if (ev.key === 'Tab' && this.isSuggestionBoxOpen()) {
238 // Cycle through tabs, or loop back to the first tab
239 this.setState({ selectedTab: ((this.state.selectedTab + 1) % Object.keys(this.state.tabbedListContents).length)} || 0)
241 if (ev.key === 'ArrowUp') {
243 this.updateSelectedSuggestionIndex(-1);
244 } else if (ev.key === 'ArrowDown') {
246 this.updateSelectedSuggestionIndex(1);
250 updateSelectedSuggestionIndex(value: -1 | 1) {
251 const { suggestions = [], category } = this.props;
252 const { tabbedListContents, selectedTab, selectedSuggestionIndex } = this.state;
253 const tabLabels = Object.keys(tabbedListContents);
254 const currentList = category === AutocompleteCat.SHARING ? tabbedListContents[tabLabels[selectedTab]] : suggestions;
255 if(selectedSuggestionIndex <= 0 && value === -1) {
256 this.setState({selectedSuggestionIndex: currentList.length - 1});
258 this.setState(({ selectedSuggestionIndex }) => ({
259 selectedSuggestionIndex: (selectedSuggestionIndex + value) % currentList.length,
265 const { items, onDelete } = this.props;
268 * If input startAdornment prop is not undefined, input's label will stay above the input.
269 * If there is not items, we want the label to go back to placeholder position.
270 * That why we return without a value instead of returning a result of a _map_ which is an empty array.
272 if (items.length === 0) {
278 const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : '';
279 if (tooltip && tooltip.length) {
280 return <span key={index}>
281 <Tooltip title={tooltip}>
283 label={this.renderChipValue(item)}
285 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} />
288 return <span key={index}><Chip
289 label={this.renderChipValue(item)}
290 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} /></span>
296 renderChipValue(value: Value) {
297 const { renderChipValue } = this.props;
298 return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
301 preventBlur = (event: React.MouseEvent<HTMLElement>) => {
302 event.preventDefault();
305 handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
306 if (event.target !== this.inputRef.current) {
307 this.setState({ suggestionsOpen: false });
311 handleSelect = (suggestion: Suggestion) => {
313 const { onSelect = noop } = this.props;
314 const { current } = this.inputRef;
318 onSelect(suggestion);
322 renderSuggestion(suggestion: Suggestion) {
323 const { renderSuggestion } = this.props;
324 return renderSuggestion
325 ? renderSuggestion(suggestion)
326 : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
329 renderSharingSuggestion = (suggestion: Suggestion) => {
330 return <ListItemText>
331 <Typography className={this.props.classes.listItemStyle} data-cy="sharing-suggestion">
332 { isGroup(suggestion) ? (suggestion as any).name : `${(suggestion as any).fullName} (${(suggestion as any).username})` }
337 getSuggestionsWidth() {
338 return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
342 type ChipClasses = 'root';
344 const chipStyles: CustomStyleRulesCallback<ChipClasses> = theme => ({
346 marginRight: theme.spacing(0.25),
347 height: theme.spacing(3),
351 const Chip = withStyles(chipStyles)(MuiChip);
353 type PopperClasses = 'root';
355 const popperStyles: CustomStyleRulesCallback<ChipClasses> = theme => ({
357 zIndex: theme.zIndex.modal,
361 const Popper = withStyles(popperStyles)(
362 ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
363 <MuiPopper {...props} className={classes.root} />
366 type InputClasses = 'root';
368 const inputStyles: CustomStyleRulesCallback<InputClasses> = () => ({
379 const Input = withStyles(inputStyles)(MuiInput);
381 const Paper = withStyles({