import React, { Component } from 'react'; import { Button, Input, InputGroup, InputGroupAddon, InputGroupText } from 'reactstrap'; import Downshift, { ControllerStateAndHelpers } from 'downshift'; import sanitizeHTML from 'sanitize-html'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faGlobeEurope, faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons'; import MetricsExplorer from './MetricsExplorer'; import { Fuzzy, FuzzyResult } from '@nexucis/fuzzy'; interface ExpressionInputProps { value: string; onExpressionChange: (expr: string) => void; queryHistory: string[]; metricNames: string[]; executeQuery: () => void; loading: boolean; enableAutocomplete: boolean; } interface ExpressionInputState { height: number | string; showMetricsExplorer: boolean; } const fuz = new Fuzzy({ pre: '', post: '', shouldSort: true }); class ExpressionInput extends Component { private exprInputRef = React.createRef(); constructor(props: ExpressionInputProps) { super(props); this.state = { height: 'auto', showMetricsExplorer: false, }; } componentDidMount(): void { this.setHeight(); } setHeight = (): void => { if (this.exprInputRef.current) { const { offsetHeight, clientHeight, scrollHeight } = this.exprInputRef.current; const offset = offsetHeight - clientHeight; // Needed in order for the height to be more accurate. this.setState({ height: scrollHeight + offset }); } }; handleInput = (): void => { if (this.exprInputRef.current) { this.setValue(this.exprInputRef.current.value); } }; setValue = (value: string): void => { const { onExpressionChange } = this.props; onExpressionChange(value); this.setState({ height: 'auto' }, this.setHeight); }; componentDidUpdate(prevProps: ExpressionInputProps): void { const { value } = this.props; if (value !== prevProps.value) { this.setValue(value); } } handleKeyPress = (event: React.KeyboardEvent): void => { const { executeQuery } = this.props; if (event.key === 'Enter' && !event.shiftKey) { executeQuery(); event.preventDefault(); } }; getSearchMatches = (input: string, expressions: string[]): FuzzyResult[] => { return fuz.filter(input.replace(/ /g, ''), expressions); }; // eslint-disable-next-line @typescript-eslint/no-explicit-any createAutocompleteSection = (downshift: ControllerStateAndHelpers): JSX.Element | null => { const { inputValue = '', closeMenu, highlightedIndex } = downshift; const autocompleteSections = { 'Query History': this.props.queryHistory, 'Metric Names': this.props.metricNames, }; let index = 0; const sections = inputValue?.length && this.props.enableAutocomplete ? Object.entries(autocompleteSections).reduce((acc, [title, items]) => { const matches = this.getSearchMatches(inputValue, items); return !matches.length ? acc : [ ...acc,
  • {title}
  • {matches .slice(0, 100) // Limit DOM rendering to 100 results, as DOM rendering is slow. .map((result: FuzzyResult) => { const itemProps = downshift.getItemProps({ key: result.original, index, item: result.original, style: { backgroundColor: highlightedIndex === index++ ? 'lightgray' : 'white', }, }); return (
  • ); })}
, ]; }, [] as JSX.Element[]) : []; if (!sections.length) { // This is ugly but is needed in order to sync state updates. // This way we force downshift to wait React render call to complete before closeMenu to be triggered. setTimeout(closeMenu); return null; } return (
{sections}
); }; openMetricsExplorer = (): void => { this.setState({ showMetricsExplorer: true, }); }; updateShowMetricsExplorer = (show: boolean): void => { this.setState({ showMetricsExplorer: show, }); }; insertAtCursor = (value: string): void => { if (!this.exprInputRef.current) return; const startPosition = this.exprInputRef.current.selectionStart; const endPosition = this.exprInputRef.current.selectionEnd; const previousValue = this.exprInputRef.current.value; let newValue: string; if (startPosition && endPosition) { newValue = previousValue.substring(0, startPosition) + value + previousValue.substring(endPosition, previousValue.length); } else { newValue = previousValue + value; } this.setValue(newValue); }; render(): JSX.Element { const { executeQuery, value } = this.props; const { height } = this.state; return ( <> {(downshift) => (
{this.props.loading ? : } { switch (event.key) { case 'Home': case 'End': // We want to be able to jump to the beginning/end of the input field. // By default, Downshift otherwise jumps to the first/last suggestion item instead. // eslint-disable-next-line @typescript-eslint/no-explicit-any (event.nativeEvent as any).preventDownshiftDefault = true; break; case 'ArrowUp': case 'ArrowDown': if (!downshift.isOpen) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (event.nativeEvent as any).preventDownshiftDefault = true; } break; case 'Enter': downshift.closeMenu(); break; case 'Escape': if (!downshift.isOpen && this.exprInputRef.current) { this.exprInputRef.current.blur(); } break; default: } }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any)} value={value} /> {downshift.isOpen && this.createAutocompleteSection(downshift)}
)}
); } } export default ExpressionInput;