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( prevProps.items.length !== this.props.items.length) {
90 this.setState({ selectedTab: 0, selectedSuggestionIndex: 0 });
92 if (Object.keys(this.state.tabbedListContents).length === 0) {
93 this.setState({ tabbedListContents: { groups: [], users: [] } });
95 if (prevProps.suggestions !== suggestions) {
96 const users = sortByKey<Suggestion>(suggestions.filter(item => !isGroup(item)), 'fullName');
97 const groups = sortByKey<Suggestion>(suggestions.filter(item => isGroup(item)), 'name');
98 this.setState({ tabbedListContents: { groups: groups, users: users } });
100 if (prevState.selectedTab !== this.state.selectedTab) {
101 this.setState({ selectedSuggestionIndex: 0 });
106 containerRef = React.createRef<HTMLDivElement>();
107 inputRef = React.createRef<HTMLInputElement>();
110 return <div ref={this.containerRef}>
111 <FormControl variant="standard" fullWidth error={this.props.error}>
114 {this.renderHelperText()}
115 {this.props.category === AutocompleteCat.SHARING ? this.renderTabbedSuggestions() : this.renderSuggestions()}
121 const { label } = this.props;
122 return label && <InputLabel>{label}</InputLabel>;
127 disabled={this.props.disabled}
128 autoFocus={this.props.autofocus}
129 inputRef={this.inputRef}
130 value={this.props.value}
131 startAdornment={this.renderChips()}
132 onFocus={this.handleFocus}
133 onBlur={this.handleBlur}
134 onChange={this.props.onChange}
135 onKeyPress={this.handleKeyPress}
136 onKeyDown={this.handleNavigationKeyPress}
141 return <FormHelperText>{this.props.helperText}</FormHelperText>;
144 renderSuggestions() {
145 const { suggestions = [] } = this.props;
148 open={this.isSuggestionBoxOpen()}
149 anchorEl={this.inputRef.current}
150 key={suggestions.length}>
151 <Paper onMouseDown={this.preventBlur}>
152 <List dense style={{ width: this.getSuggestionsWidth() }}>
154 (suggestion, index) =>
158 onClick={this.handleSelect(suggestion)}
159 selected={index === this.state.selectedSuggestionIndex}>
160 {this.renderSuggestion(suggestion)}
169 renderTabbedSuggestions() {
170 const { suggestions = [] } = this.props;
174 open={this.state.suggestionsOpen}
175 anchorEl={this.containerRef.current || this.inputRef.current}
176 key={suggestions.length}
177 style={{ width: this.getSuggestionsWidth()}}
179 <Paper onMouseDown={this.preventBlur}>
181 tabbedListContents={this.state.tabbedListContents}
182 renderListItem={this.renderSharingSuggestion}
183 selectedIndex={this.state.selectedSuggestionIndex}
184 selectedTab={this.state.selectedTab}
185 handleTabChange={this.handleTabChange}
186 handleSelect={this.handleSelect}
187 includeContentsLength={true}
188 isWorking={this.props.isWorking}
189 maxLength={this.props.maxLength}
196 isSuggestionBoxOpen() {
197 const { suggestions = [] } = this.props;
198 return this.state.suggestionsOpen && suggestions.length > 0;
201 handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
202 const { onFocus = noop } = this.props;
203 this.setState({ suggestionsOpen: true, selectedTab: 0 });
207 handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
209 const { onBlur = noop } = this.props;
210 this.setState({ suggestionsOpen: false });
215 handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
216 event.preventDefault();
217 this.setState({ selectedTab: newValue });
220 handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
221 const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props;
222 const { selectedSuggestionIndex, selectedTab } = this.state;
223 if (event.key === 'Enter') {
224 if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
225 // prevent form submissions when selecting a suggestion
226 event.preventDefault();
227 if(this.props.category === AutocompleteCat.SHARING) {
228 onSelect(this.state.tabbedListContents[Object.keys(this.state.tabbedListContents)[selectedTab]][selectedSuggestionIndex]);
230 onSelect(suggestions[selectedSuggestionIndex]);
232 } else if (this.props.value.length > 0) {
238 handleNavigationKeyPress = (ev: React.KeyboardEvent<HTMLInputElement>) => {
239 if (ev.key === 'Tab' && this.isSuggestionBoxOpen() && this.props.category === AutocompleteCat.SHARING) {
241 // Cycle through tabs, or loop back to the first tab
242 this.setState({ selectedTab: ((this.state.selectedTab + 1) % Object.keys(this.state.tabbedListContents).length)} || 0)
244 if (ev.key === 'ArrowUp') {
246 this.updateSelectedSuggestionIndex(-1);
247 } else if (ev.key === 'ArrowDown') {
249 this.updateSelectedSuggestionIndex(1);
253 updateSelectedSuggestionIndex(value: -1 | 1) {
254 const { suggestions = [], category } = this.props;
255 const { tabbedListContents, selectedTab, selectedSuggestionIndex } = this.state;
256 const tabLabels = Object.keys(tabbedListContents);
257 const currentList = category === AutocompleteCat.SHARING ? tabbedListContents[tabLabels[selectedTab]] : suggestions;
258 if(selectedSuggestionIndex <= 0 && value === -1) {
259 this.setState({selectedSuggestionIndex: currentList.length - 1});
261 this.setState(({ selectedSuggestionIndex }) => ({
262 selectedSuggestionIndex: (selectedSuggestionIndex + value) % currentList.length,
268 const { items, onDelete } = this.props;
271 * If input startAdornment prop is not undefined, input's label will stay above the input.
272 * If there is not items, we want the label to go back to placeholder position.
273 * That why we return without a value instead of returning a result of a _map_ which is an empty array.
275 if (items.length === 0) {
281 const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : '';
282 if (tooltip && tooltip.length) {
283 return <span key={index}>
284 <Tooltip title={tooltip}>
286 label={this.renderChipValue(item)}
288 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} />
291 return <span key={index}><Chip
292 label={this.renderChipValue(item)}
293 onDelete={onDelete && !this.props.disabled ? (() => onDelete(item, index)) : undefined} /></span>
299 renderChipValue(value: Value) {
300 const { renderChipValue } = this.props;
301 return renderChipValue ? renderChipValue(value) : JSON.stringify(value);
304 preventBlur = (event: React.MouseEvent<HTMLElement>) => {
305 event.preventDefault();
308 handleClickAway = (event: React.MouseEvent<HTMLElement>) => {
309 if (event.target !== this.inputRef.current) {
310 this.setState({ suggestionsOpen: false });
314 handleSelect = (suggestion: Suggestion) => {
316 const { onSelect = noop } = this.props;
317 const { current } = this.inputRef;
321 onSelect(suggestion);
325 renderSuggestion(suggestion: Suggestion) {
326 const { renderSuggestion } = this.props;
327 return renderSuggestion
328 ? renderSuggestion(suggestion)
329 : <ListItemText>{JSON.stringify(suggestion)}</ListItemText>;
332 renderSharingSuggestion = (suggestion: Suggestion) => {
333 return <ListItemText>
334 <Typography className={this.props.classes.listItemStyle} data-cy="sharing-suggestion">
335 { isGroup(suggestion) ? `${(suggestion as any).name}` : `${(suggestion as any).fullName} (${(suggestion as any).email})` }
340 getSuggestionsWidth() {
341 return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto';
345 type ChipClasses = 'root';
347 const chipStyles: CustomStyleRulesCallback<ChipClasses> = theme => ({
349 marginRight: theme.spacing(0.25),
350 height: theme.spacing(3),
354 const Chip = withStyles(chipStyles)(MuiChip);
356 type PopperClasses = 'root';
358 const popperStyles: CustomStyleRulesCallback<ChipClasses> = theme => ({
360 zIndex: theme.zIndex.modal,
364 const Popper = withStyles(popperStyles)(
365 ({ classes, ...props }: PopperProps & WithStyles<PopperClasses>) =>
366 <MuiPopper {...props} className={classes.root} />
369 type InputClasses = 'root';
371 const inputStyles: CustomStyleRulesCallback<InputClasses> = () => ({
382 const Input = withStyles(inputStyles)(MuiInput);
384 const Paper = withStyles({