// Copyright (C) The Arvados Authors. All rights reserved. // // SPDX-License-Identifier: AGPL-3.0 import React from 'react'; import { Input as MuiInput, Chip as MuiChip, Popper as MuiPopper, Paper as MuiPaper, FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List, FormHelperText, Tooltip, Typography } from '@material-ui/core'; import { PopperProps } from '@material-ui/core/Popper'; import { WithStyles } from '@material-ui/core/styles'; import { noop } from 'lodash'; import { isGroup } from 'common/isGroup'; import { sortByKey } from 'common/objects'; export interface AutocompleteProps { label?: string; value: string; items: Item[]; disabled?: boolean; suggestions?: Suggestion[]; error?: boolean; helperText?: string; autofocus?: boolean; onChange: (event: React.ChangeEvent) => void; onBlur?: (event: React.FocusEvent) => void; onFocus?: (event: React.FocusEvent) => void; onCreate?: () => void; onDelete?: (item: Item, index: number) => void; onSelect?: (suggestion: Suggestion) => void; renderChipValue?: (item: Item) => string; renderChipTooltip?: (item: Item) => string; renderSuggestion?: (suggestion: Suggestion) => React.ReactNode; category?: AutocompleteCat; } type AutocompleteClasses = 'sharingList' | 'emptyList' | 'listSubHeader' | 'numFound'; const autocompleteStyles: StyleRulesCallback = theme => ({ sharingList: { maxHeight: '10rem', overflowY: 'scroll', scrollbarColor: 'rgba(0, 0, 0, 0.3) rgba(0, 0, 0, 0)', '&::-webkit-scrollbar': { width: '0.4em', }, '&::-webkit-scrollbar-thumb': { backgroundColor: 'rgba(0, 0, 0, 0.3)', borderRadius: '4px', }, '&::-webkit-scrollbar-track': { backgroundColor: 'rgba(0, 0, 0, 0)', }, }, emptyList: { padding: '0.5rem', fontStyle: 'italic', }, listSubHeader: { padding: '0.5rem', display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', }, numFound: { fontStyle: 'italic', fontSize: '0.8rem', }, }); export enum AutocompleteCat { SHARING = 'sharing', }; export interface AutocompleteState { suggestionsOpen: boolean; selectedSuggestionIndex: number; } export const Autocomplete = withStyles(autocompleteStyles)( class Autocomplete extends React.Component & WithStyles, AutocompleteState> { state = { suggestionsOpen: false, selectedSuggestionIndex: 0, }; containerRef = React.createRef(); inputRef = React.createRef(); render() { return ( {this.renderLabel()} {this.renderInput()} {this.renderHelperText()} {this.props.category === AutocompleteCat.SHARING ? this.renderSharingSuggestions() : this.renderSuggestions()} ); } renderLabel() { const { label } = this.props; return label && {label}; } renderInput() { return ; } renderHelperText() { return {this.props.helperText}; } renderSuggestions() { const { suggestions = [] } = this.props; return ( {suggestions.map( (suggestion, index) => {this.renderSuggestion(suggestion)} )} ); } renderSharingSuggestions() { const { suggestions = [], classes } = this.props; const users = sortByKey(suggestions.filter(item => !isGroup(item)), 'fullName'); const groups = sortByKey(suggestions.filter(item => isGroup(item)), 'name'); return (
Groups {{groups.length} {groups.length === 1 ? 'match' : 'matches'} found}
{groups.map( (suggestion, index) => {this.renderSharingSuggestion(suggestion)} )}
Users {{users.length} {users.length === 1 ? 'match' : 'matches'} found}
{users.map( (suggestion, index) => {this.renderSharingSuggestion(suggestion)} )}
); } isSuggestionBoxOpen() { const { suggestions = [] } = this.props; return this.state.suggestionsOpen && suggestions.length > 0; } handleFocus = (event: React.FocusEvent) => { const { onFocus = noop } = this.props; this.setState({ suggestionsOpen: true }); onFocus(event); } handleBlur = (event: React.FocusEvent) => { setTimeout(() => { const { onBlur = noop } = this.props; this.setState({ suggestionsOpen: false }); onBlur(event); }); } handleKeyPress = (event: React.KeyboardEvent) => { const { onCreate = noop, onSelect = noop, suggestions = [] } = this.props; const { selectedSuggestionIndex } = this.state; if (event.key === 'Enter') { if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) { // prevent form submissions when selecting a suggestion event.preventDefault(); onSelect(suggestions[selectedSuggestionIndex]); } else if (this.props.value.length > 0) { onCreate(); } } } handleNavigationKeyPress = ({ key }: React.KeyboardEvent) => { if (key === 'ArrowUp') { this.updateSelectedSuggestionIndex(-1); } else if (key === 'ArrowDown') { this.updateSelectedSuggestionIndex(1); } } updateSelectedSuggestionIndex(value: -1 | 1) { const { suggestions = [] } = this.props; this.setState(({ selectedSuggestionIndex }) => ({ selectedSuggestionIndex: (selectedSuggestionIndex + value) % suggestions.length })); } renderChips() { const { items, onDelete } = this.props; /** * If input startAdornment prop is not undefined, input's label will stay above the input. * If there is not items, we want the label to go back to placeholder position. * That why we return without a value instead of returning a result of a _map_ which is an empty array. */ if (items.length === 0) { return; } return items.map( (item, index) => { const tooltip = this.props.renderChipTooltip ? this.props.renderChipTooltip(item) : ''; if (tooltip && tooltip.length) { return onDelete(item, index)) : undefined} /> } else { return onDelete(item, index)) : undefined} /> } } ); } renderChipValue(value: Value) { const { renderChipValue } = this.props; return renderChipValue ? renderChipValue(value) : JSON.stringify(value); } preventBlur = (event: React.MouseEvent) => { event.preventDefault(); } handleClickAway = (event: React.MouseEvent) => { if (event.target !== this.inputRef.current) { this.setState({ suggestionsOpen: false }); } } handleSelect(suggestion: Suggestion) { return () => { const { onSelect = noop } = this.props; const { current } = this.inputRef; if (current) { current.focus(); } onSelect(suggestion); }; } renderSuggestion(suggestion: Suggestion) { const { renderSuggestion } = this.props; return renderSuggestion ? renderSuggestion(suggestion) : {JSON.stringify(suggestion)}; } renderSharingSuggestion(suggestion: Suggestion) { if (isGroup(suggestion)) { return {(suggestion as any).name} ;} return {`${(suggestion as any).fullName} (${(suggestion as any).username})`} ; } getSuggestionsWidth() { return this.containerRef.current ? this.containerRef.current.offsetWidth : 'auto'; } }); type ChipClasses = 'root'; const chipStyles: StyleRulesCallback = theme => ({ root: { marginRight: theme.spacing.unit / 4, height: theme.spacing.unit * 3, } }); const Chip = withStyles(chipStyles)(MuiChip); type PopperClasses = 'root'; const popperStyles: StyleRulesCallback = theme => ({ root: { zIndex: theme.zIndex.modal, } }); const Popper = withStyles(popperStyles)( ({ classes, ...props }: PopperProps & WithStyles) => ); type InputClasses = 'root'; const inputStyles: StyleRulesCallback = () => ({ root: { display: 'flex', flexWrap: 'wrap', }, input: { minWidth: '20%', flex: 1, }, }); const Input = withStyles(inputStyles)(MuiInput); const Paper = withStyles({ root: { maxHeight: '80vh', overflowY: 'auto', } })(MuiPaper);