2019-10-17 05:38:09 -07:00
|
|
|
import React, { Component } from 'react';
|
2019-11-03 03:47:47 -08:00
|
|
|
import { Button, InputGroup, InputGroupAddon, InputGroupText, Input } from 'reactstrap';
|
2019-10-17 05:38:09 -07:00
|
|
|
|
2019-10-20 13:52:29 -07:00
|
|
|
import Downshift, { ControllerStateAndHelpers } from 'downshift';
|
2019-10-17 05:38:09 -07:00
|
|
|
import fuzzy from 'fuzzy';
|
2020-01-14 10:34:48 -08:00
|
|
|
import sanitizeHTML from 'sanitize-html';
|
2019-10-17 05:38:09 -07:00
|
|
|
|
|
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
2021-02-19 14:42:20 -08:00
|
|
|
import { faSearch, faSpinner, faGlobeEurope } from '@fortawesome/free-solid-svg-icons';
|
|
|
|
import MetricsExplorer from './MetricsExplorer';
|
2019-10-17 05:38:09 -07:00
|
|
|
|
|
|
|
interface ExpressionInputProps {
|
|
|
|
value: string;
|
2019-12-12 14:22:12 -08:00
|
|
|
onExpressionChange: (expr: string) => void;
|
2021-02-19 14:42:20 -08:00
|
|
|
queryHistory: string[];
|
|
|
|
metricNames: string[];
|
2019-12-12 14:22:12 -08:00
|
|
|
executeQuery: () => void;
|
2019-10-17 05:38:09 -07:00
|
|
|
loading: boolean;
|
2020-11-12 02:48:48 -08:00
|
|
|
enableAutocomplete: boolean;
|
2019-10-17 05:38:09 -07:00
|
|
|
}
|
|
|
|
|
2019-10-20 13:52:29 -07:00
|
|
|
interface ExpressionInputState {
|
|
|
|
height: number | string;
|
2021-02-19 14:42:20 -08:00
|
|
|
showMetricsExplorer: boolean;
|
2019-10-20 13:52:29 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputState> {
|
2019-10-17 05:38:09 -07:00
|
|
|
private exprInputRef = React.createRef<HTMLInputElement>();
|
|
|
|
|
2019-10-20 13:52:29 -07:00
|
|
|
constructor(props: ExpressionInputProps) {
|
|
|
|
super(props);
|
|
|
|
this.state = {
|
2019-10-28 07:02:42 -07:00
|
|
|
height: 'auto',
|
2021-02-19 14:42:20 -08:00
|
|
|
showMetricsExplorer: false,
|
2019-10-28 07:02:42 -07:00
|
|
|
};
|
2019-10-20 13:52:29 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
componentDidMount() {
|
|
|
|
this.setHeight();
|
|
|
|
}
|
|
|
|
|
|
|
|
setHeight = () => {
|
|
|
|
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 });
|
2019-10-28 07:02:42 -07:00
|
|
|
};
|
2019-10-20 13:52:29 -07:00
|
|
|
|
|
|
|
handleInput = () => {
|
2019-11-12 01:21:23 -08:00
|
|
|
this.setValue(this.exprInputRef.current!.value);
|
2019-10-28 07:02:42 -07:00
|
|
|
};
|
2019-10-20 13:52:29 -07:00
|
|
|
|
2019-11-12 01:21:23 -08:00
|
|
|
setValue = (value: string) => {
|
2019-12-12 14:22:12 -08:00
|
|
|
const { onExpressionChange } = this.props;
|
|
|
|
onExpressionChange(value);
|
|
|
|
this.setState({ height: 'auto' }, this.setHeight);
|
2019-10-23 13:18:41 -07:00
|
|
|
};
|
|
|
|
|
2019-11-12 01:21:23 -08:00
|
|
|
componentDidUpdate(prevProps: ExpressionInputProps) {
|
|
|
|
const { value } = this.props;
|
|
|
|
if (value !== prevProps.value) {
|
|
|
|
this.setValue(value);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-10-17 05:38:09 -07:00
|
|
|
handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
2019-12-12 14:22:12 -08:00
|
|
|
const { executeQuery } = this.props;
|
2019-10-17 05:38:09 -07:00
|
|
|
if (event.key === 'Enter' && !event.shiftKey) {
|
2019-12-12 14:22:12 -08:00
|
|
|
executeQuery();
|
2019-10-17 05:38:09 -07:00
|
|
|
event.preventDefault();
|
|
|
|
}
|
2019-10-28 07:02:42 -07:00
|
|
|
};
|
2019-10-17 05:38:09 -07:00
|
|
|
|
2019-10-26 10:50:22 -07:00
|
|
|
getSearchMatches = (input: string, expressions: string[]) => {
|
|
|
|
return fuzzy.filter(input.replace(/ /g, ''), expressions, {
|
2019-10-28 07:02:42 -07:00
|
|
|
pre: '<strong>',
|
|
|
|
post: '</strong>',
|
2019-10-17 05:38:09 -07:00
|
|
|
});
|
2019-10-28 07:02:42 -07:00
|
|
|
};
|
2019-10-17 05:38:09 -07:00
|
|
|
|
2019-10-26 10:50:22 -07:00
|
|
|
createAutocompleteSection = (downshift: ControllerStateAndHelpers<any>) => {
|
|
|
|
const { inputValue = '', closeMenu, highlightedIndex } = downshift;
|
2021-02-19 14:42:20 -08:00
|
|
|
const autocompleteSections = {
|
|
|
|
'Query History': this.props.queryHistory,
|
|
|
|
'Metric Names': this.props.metricNames,
|
|
|
|
};
|
2019-10-26 10:50:22 -07:00
|
|
|
let index = 0;
|
2020-11-02 07:16:54 -08:00
|
|
|
const sections =
|
2020-11-12 02:48:48 -08:00
|
|
|
inputValue!.length && this.props.enableAutocomplete
|
2020-11-02 07:16:54 -08:00
|
|
|
? Object.entries(autocompleteSections).reduce((acc, [title, items]) => {
|
|
|
|
const matches = this.getSearchMatches(inputValue!, items);
|
|
|
|
return !matches.length
|
|
|
|
? acc
|
|
|
|
: [
|
|
|
|
...acc,
|
|
|
|
<ul className="autosuggest-dropdown-list" key={title}>
|
|
|
|
<li className="autosuggest-dropdown-header">{title}</li>
|
|
|
|
{matches
|
|
|
|
.slice(0, 100) // Limit DOM rendering to 100 results, as DOM rendering is sloooow.
|
|
|
|
.map(({ original, string: text }) => {
|
|
|
|
const itemProps = downshift.getItemProps({
|
|
|
|
key: original,
|
|
|
|
index,
|
|
|
|
item: original,
|
|
|
|
style: {
|
|
|
|
backgroundColor: highlightedIndex === index++ ? 'lightgray' : 'white',
|
|
|
|
},
|
|
|
|
});
|
|
|
|
return (
|
|
|
|
<li
|
|
|
|
key={title}
|
|
|
|
{...itemProps}
|
|
|
|
dangerouslySetInnerHTML={{ __html: sanitizeHTML(text, { allowedTags: ['strong'] }) }}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
})}
|
|
|
|
</ul>,
|
|
|
|
];
|
|
|
|
}, [] as JSX.Element[])
|
|
|
|
: [];
|
2019-10-26 10:50:22 -07:00
|
|
|
|
|
|
|
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);
|
2019-10-17 05:38:09 -07:00
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
2019-10-26 10:50:22 -07:00
|
|
|
<div {...downshift.getMenuProps()} className="autosuggest-dropdown">
|
2019-10-28 07:02:42 -07:00
|
|
|
{sections}
|
2019-10-26 10:50:22 -07:00
|
|
|
</div>
|
2019-10-17 05:38:09 -07:00
|
|
|
);
|
2019-10-28 07:02:42 -07:00
|
|
|
};
|
2019-10-17 05:38:09 -07:00
|
|
|
|
2021-02-19 14:42:20 -08:00
|
|
|
openMetricsExplorer = () => {
|
|
|
|
this.setState({
|
|
|
|
showMetricsExplorer: true,
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
updateShowMetricsExplorer = (show: boolean) => {
|
|
|
|
this.setState({
|
|
|
|
showMetricsExplorer: show,
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
|
|
|
insertAtCursor = (value: string) => {
|
|
|
|
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);
|
|
|
|
};
|
|
|
|
|
2019-10-17 05:38:09 -07:00
|
|
|
render() {
|
2019-12-12 14:22:12 -08:00
|
|
|
const { executeQuery, value } = this.props;
|
|
|
|
const { height } = this.state;
|
2019-10-17 05:38:09 -07:00
|
|
|
return (
|
2021-02-19 14:42:20 -08:00
|
|
|
<>
|
|
|
|
<Downshift onSelect={this.setValue}>
|
|
|
|
{downshift => (
|
|
|
|
<div>
|
|
|
|
<InputGroup className="expression-input">
|
|
|
|
<InputGroupAddon addonType="prepend">
|
|
|
|
<InputGroupText>
|
|
|
|
{this.props.loading ? <FontAwesomeIcon icon={faSpinner} spin /> : <FontAwesomeIcon icon={faSearch} />}
|
|
|
|
</InputGroupText>
|
|
|
|
</InputGroupAddon>
|
|
|
|
<Input
|
|
|
|
onInput={this.handleInput}
|
|
|
|
style={{ height }}
|
|
|
|
autoFocus
|
|
|
|
type="textarea"
|
|
|
|
rows="1"
|
|
|
|
onKeyPress={this.handleKeyPress}
|
|
|
|
placeholder="Expression (press Shift+Enter for newlines)"
|
|
|
|
innerRef={this.exprInputRef}
|
|
|
|
{...downshift.getInputProps({
|
|
|
|
onKeyDown: (event: React.KeyboardEvent): void => {
|
|
|
|
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.
|
2019-10-17 05:38:09 -07:00
|
|
|
(event.nativeEvent as any).preventDownshiftDefault = true;
|
2021-02-19 14:42:20 -08:00
|
|
|
break;
|
|
|
|
case 'ArrowUp':
|
|
|
|
case 'ArrowDown':
|
|
|
|
if (!downshift.isOpen) {
|
|
|
|
(event.nativeEvent as any).preventDownshiftDefault = true;
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
case 'Enter':
|
|
|
|
downshift.closeMenu();
|
|
|
|
break;
|
|
|
|
case 'Escape':
|
|
|
|
if (!downshift.isOpen) {
|
|
|
|
this.exprInputRef.current!.blur();
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
default:
|
|
|
|
}
|
|
|
|
},
|
|
|
|
} as any)}
|
|
|
|
value={value}
|
|
|
|
/>
|
|
|
|
<InputGroupAddon addonType="append">
|
2021-04-15 09:14:07 -07:00
|
|
|
<Button className="metrics-explorer-btn" title="Open metrics explorer" onClick={this.openMetricsExplorer}>
|
2021-02-19 14:42:20 -08:00
|
|
|
<FontAwesomeIcon icon={faGlobeEurope} />
|
|
|
|
</Button>
|
|
|
|
</InputGroupAddon>
|
|
|
|
<InputGroupAddon addonType="append">
|
|
|
|
<Button className="execute-btn" color="primary" onClick={executeQuery}>
|
|
|
|
Execute
|
|
|
|
</Button>
|
|
|
|
</InputGroupAddon>
|
|
|
|
</InputGroup>
|
|
|
|
{downshift.isOpen && this.createAutocompleteSection(downshift)}
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
</Downshift>
|
|
|
|
|
|
|
|
<MetricsExplorer
|
|
|
|
show={this.state.showMetricsExplorer}
|
|
|
|
updateShow={this.updateShowMetricsExplorer}
|
|
|
|
metrics={this.props.metricNames}
|
|
|
|
insertAtCursor={this.insertAtCursor}
|
|
|
|
/>
|
|
|
|
</>
|
2019-10-17 05:38:09 -07:00
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export default ExpressionInput;
|