remove old promql editor (#9452)

* remove old promql editor

Signed-off-by: Augustin Husson <husson.augustin@gmail.com>

* rename CMExpression by Expression

Signed-off-by: Augustin Husson <husson.augustin@gmail.com>
This commit is contained in:
Augustin Husson 2021-10-18 17:22:23 +02:00 committed by GitHub
parent 703d9bcd56
commit a4ad290987
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 263 additions and 822 deletions

View file

@ -1,69 +0,0 @@
import * as React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import CMExpressionInput from './CMExpressionInput';
import { Button, InputGroup, InputGroupAddon } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons';
describe('CMExpressionInput', () => {
const expressionInputProps = {
value: 'node_cpu',
queryHistory: [],
metricNames: [],
executeQuery: (): void => {
// Do nothing.
},
onExpressionChange: (): void => {
// Do nothing.
},
loading: false,
enableAutocomplete: true,
enableHighlighting: true,
enableLinter: true,
};
let expressionInput: ReactWrapper;
beforeEach(() => {
expressionInput = mount(<CMExpressionInput {...expressionInputProps} />);
});
it('renders an InputGroup', () => {
const inputGroup = expressionInput.find(InputGroup);
expect(inputGroup.prop('className')).toEqual('expression-input');
});
it('renders a search icon when it is not loading', () => {
const addon = expressionInput.find(InputGroupAddon).filterWhere((addon) => addon.prop('addonType') === 'prepend');
const icon = addon.find(FontAwesomeIcon);
expect(icon.prop('icon')).toEqual(faSearch);
});
it('renders a loading icon when it is loading', () => {
const expressionInput = mount(<CMExpressionInput {...expressionInputProps} loading={true} />);
const addon = expressionInput.find(InputGroupAddon).filterWhere((addon) => addon.prop('addonType') === 'prepend');
const icon = addon.find(FontAwesomeIcon);
expect(icon.prop('icon')).toEqual(faSpinner);
expect(icon.prop('spin')).toBe(true);
});
it('renders a CodeMirror expression input', () => {
const input = expressionInput.find('div.cm-expression-input');
expect(input.text()).toContain('node_cpu');
});
it('renders an execute button', () => {
const addon = expressionInput.find(InputGroupAddon).filterWhere((addon) => addon.prop('addonType') === 'append');
const button = addon.find(Button).find('.execute-btn').first();
expect(button.prop('color')).toEqual('primary');
expect(button.text()).toEqual('Execute');
});
it('executes the query when clicking the execute button', () => {
const spyExecuteQuery = jest.fn();
const props = { ...expressionInputProps, executeQuery: spyExecuteQuery };
const wrapper = mount(<CMExpressionInput {...props} />);
const btn = wrapper.find(Button).filterWhere((btn) => btn.hasClass('execute-btn'));
btn.simulate('click');
expect(spyExecuteQuery).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,249 +0,0 @@
import React, { FC, useState, useEffect, useRef } from 'react';
import { Button, InputGroup, InputGroupAddon, InputGroupText } from 'reactstrap';
import { EditorView, highlightSpecialChars, keymap, ViewUpdate, placeholder } from '@codemirror/view';
import { EditorState, Prec, Compartment } from '@codemirror/state';
import { indentOnInput, syntaxTree } from '@codemirror/language';
import { history, historyKeymap } from '@codemirror/history';
import { defaultKeymap, insertNewlineAndIndent } from '@codemirror/commands';
import { bracketMatching } from '@codemirror/matchbrackets';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets';
import { highlightSelectionMatches } from '@codemirror/search';
import { commentKeymap } from '@codemirror/comment';
import { lintKeymap } from '@codemirror/lint';
import { autocompletion, completionKeymap, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { baseTheme, lightTheme, darkTheme, promqlHighlighter } from './CMTheme';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSearch, faSpinner, faGlobeEurope } from '@fortawesome/free-solid-svg-icons';
import MetricsExplorer from './MetricsExplorer';
import { usePathPrefix } from '../../contexts/PathPrefixContext';
import { useTheme } from '../../contexts/ThemeContext';
import { CompleteStrategy, PromQLExtension } from 'codemirror-promql';
import { newCompleteStrategy } from 'codemirror-promql/dist/cjs/complete';
const promqlExtension = new PromQLExtension();
interface CMExpressionInputProps {
value: string;
onExpressionChange: (expr: string) => void;
queryHistory: string[];
metricNames: string[];
executeQuery: () => void;
loading: boolean;
enableAutocomplete: boolean;
enableHighlighting: boolean;
enableLinter: boolean;
}
const dynamicConfigCompartment = new Compartment();
// Autocompletion strategy that wraps the main one and enriches
// it with past query items.
export class HistoryCompleteStrategy implements CompleteStrategy {
private complete: CompleteStrategy;
private queryHistory: string[];
constructor(complete: CompleteStrategy, queryHistory: string[]) {
this.complete = complete;
this.queryHistory = queryHistory;
}
promQL(context: CompletionContext): Promise<CompletionResult | null> | CompletionResult | null {
return Promise.resolve(this.complete.promQL(context)).then((res) => {
const { state, pos } = context;
const tree = syntaxTree(state).resolve(pos, -1);
const start = res != null ? res.from : tree.from;
if (start !== 0) {
return res;
}
const historyItems: CompletionResult = {
from: start,
to: pos,
options: this.queryHistory.map((q) => ({
label: q.length < 80 ? q : q.slice(0, 76).concat('...'),
detail: 'past query',
apply: q,
info: q.length < 80 ? undefined : q,
})),
span: /^[a-zA-Z0-9_:]+$/,
};
if (res !== null) {
historyItems.options = historyItems.options.concat(res.options);
}
return historyItems;
});
}
}
const CMExpressionInput: FC<CMExpressionInputProps> = ({
value,
onExpressionChange,
queryHistory,
metricNames,
executeQuery,
loading,
enableAutocomplete,
enableHighlighting,
enableLinter,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const [showMetricsExplorer, setShowMetricsExplorer] = useState<boolean>(false);
const pathPrefix = usePathPrefix();
const { theme } = useTheme();
// (Re)initialize editor based on settings / setting changes.
useEffect(() => {
// Build the dynamic part of the config.
promqlExtension
.activateCompletion(enableAutocomplete)
.activateLinter(enableLinter)
.setComplete({
completeStrategy: new HistoryCompleteStrategy(
newCompleteStrategy({
remote: { url: pathPrefix, cache: { initialMetricList: metricNames } },
}),
queryHistory
),
});
const dynamicConfig = [
enableHighlighting ? promqlHighlighter : [],
promqlExtension.asExtension(),
theme === 'dark' ? darkTheme : lightTheme,
];
// Create or reconfigure the editor.
const view = viewRef.current;
if (view === null) {
// If the editor does not exist yet, create it.
if (!containerRef.current) {
throw new Error('expected CodeMirror container element to exist');
}
const startState = EditorState.create({
doc: value,
extensions: [
baseTheme,
highlightSpecialChars(),
history(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
bracketMatching(),
closeBrackets(),
autocompletion(),
highlightSelectionMatches(),
EditorView.lineWrapping,
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
...commentKeymap,
...completionKeymap,
...lintKeymap,
]),
placeholder('Expression (press Shift+Enter for newlines)'),
dynamicConfigCompartment.of(dynamicConfig),
// This keymap is added without precedence so that closing the autocomplete dropdown
// via Escape works without blurring the editor.
keymap.of([
{
key: 'Escape',
run: (v: EditorView): boolean => {
v.contentDOM.blur();
return false;
},
},
]),
Prec.override(
keymap.of([
{
key: 'Enter',
run: (v: EditorView): boolean => {
executeQuery();
return true;
},
},
{
key: 'Shift-Enter',
run: insertNewlineAndIndent,
},
])
),
EditorView.updateListener.of((update: ViewUpdate): void => {
onExpressionChange(update.state.doc.toString());
}),
],
});
const view = new EditorView({
state: startState,
parent: containerRef.current,
});
viewRef.current = view;
view.focus();
} else {
// The editor already exists, just reconfigure the dynamically configured parts.
view.dispatch(
view.state.update({
effects: dynamicConfigCompartment.reconfigure(dynamicConfig),
})
);
}
// "value" is only used in the initial render, so we don't want to
// re-run this effect every time that "value" changes.
//
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enableAutocomplete, enableHighlighting, enableLinter, executeQuery, onExpressionChange, queryHistory, theme]);
const insertAtCursor = (value: string) => {
const view = viewRef.current;
if (view === null) {
return;
}
const { from, to } = view.state.selection.ranges[0];
view.dispatch(
view.state.update({
changes: { from, to, insert: value },
})
);
};
return (
<>
<InputGroup className="expression-input">
<InputGroupAddon addonType="prepend">
<InputGroupText>
{loading ? <FontAwesomeIcon icon={faSpinner} spin /> : <FontAwesomeIcon icon={faSearch} />}
</InputGroupText>
</InputGroupAddon>
<div ref={containerRef} className="cm-expression-input" />
<InputGroupAddon addonType="append">
<Button
className="metrics-explorer-btn"
title="Open metrics explorer"
onClick={() => setShowMetricsExplorer(true)}
>
<FontAwesomeIcon icon={faGlobeEurope} />
</Button>
<Button className="execute-btn" color="primary" onClick={executeQuery}>
Execute
</Button>
</InputGroupAddon>
</InputGroup>
<MetricsExplorer
show={showMetricsExplorer}
updateShow={setShowMetricsExplorer}
metrics={metricNames}
insertAtCursor={insertAtCursor}
/>
</>
);
};
export default CMExpressionInput;

View file

@ -1,26 +1,15 @@
import * as React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import ExpressionInput from './ExpressionInput';
import Downshift from 'downshift';
import { Button, InputGroup, InputGroupAddon, Input } from 'reactstrap';
import { Button, InputGroup, InputGroupAddon } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons';
const getKeyEvent = (key: string): React.KeyboardEvent<HTMLInputElement> =>
({
key,
nativeEvent: {},
preventDefault: () => {
// Do nothing.
},
} as React.KeyboardEvent<HTMLInputElement>);
describe('ExpressionInput', () => {
const metricNames = ['instance:node_cpu_utilisation:rate1m', 'node_cpu_guest_seconds_total', 'node_cpu_seconds_total'];
const expressionInputProps = {
value: 'node_cpu',
queryHistory: [],
metricNames,
metricNames: [],
executeQuery: (): void => {
// Do nothing.
},
@ -29,6 +18,8 @@ describe('ExpressionInput', () => {
},
loading: false,
enableAutocomplete: true,
enableHighlighting: true,
enableLinter: true,
};
let expressionInput: ReactWrapper;
@ -36,11 +27,6 @@ describe('ExpressionInput', () => {
expressionInput = mount(<ExpressionInput {...expressionInputProps} />);
});
it('renders a downshift component', () => {
const downshift = expressionInput.find(Downshift);
expect(downshift).toHaveLength(1);
});
it('renders an InputGroup', () => {
const inputGroup = expressionInput.find(InputGroup);
expect(inputGroup.prop('className')).toEqual('expression-input');
@ -60,205 +46,24 @@ describe('ExpressionInput', () => {
expect(icon.prop('spin')).toBe(true);
});
it('renders an Input', () => {
const input = expressionInput.find(Input);
expect(input.prop('style')).toEqual({ height: 0 });
expect(input.prop('autoFocus')).toEqual(true);
expect(input.prop('type')).toEqual('textarea');
expect(input.prop('rows')).toEqual('1');
expect(input.prop('placeholder')).toEqual('Expression (press Shift+Enter for newlines)');
expect(input.prop('value')).toEqual('node_cpu');
it('renders a CodeMirror expression input', () => {
const input = expressionInput.find('div.cm-expression-input');
expect(input.text()).toContain('node_cpu');
});
describe('when autosuggest is closed', () => {
it('prevents Downshift default on Home, End, Arrows', () => {
const downshift = expressionInput.find(Downshift);
const input = downshift.find(Input);
downshift.setState({ isOpen: false });
['Home', 'End', 'ArrowUp', 'ArrowDown'].forEach((key) => {
const event = getKeyEvent(key);
input.simulate('keydown', event);
const nativeEvent = event.nativeEvent as any;
expect(nativeEvent.preventDownshiftDefault).toBe(true);
});
});
it('does not render an autosuggest', () => {
const downshift = expressionInput.find(Downshift);
downshift.setState({ isOpen: false });
const ul = downshift.find('ul');
expect(ul).toHaveLength(0);
});
});
describe('handleInput', () => {
it('should call setState', () => {
const instance: any = expressionInput.instance();
const stateSpy = jest.spyOn(instance, 'setState');
instance.handleInput();
expect(stateSpy).toHaveBeenCalled();
});
it('should call onExpressionChange', () => {
const spyOnExpressionChange = jest.fn();
const props = { ...expressionInputProps, onExpressionChange: spyOnExpressionChange };
const wrapper = mount(<ExpressionInput {...props} />);
const input = wrapper.find(Input);
input.simulate('input', { target: { value: 'prometheus_engine_' } });
expect(spyOnExpressionChange).toHaveBeenCalledTimes(1);
});
});
describe('onSelect', () => {
it('should call setState with selected value', () => {
const instance: any = expressionInput.instance();
const stateSpy = jest.spyOn(instance, 'setState');
instance.setValue('foo');
expect(stateSpy).toHaveBeenCalledWith({ height: 'auto' }, expect.anything());
});
});
describe('onClick', () => {
it('executes the query', () => {
const spyExecuteQuery = jest.fn();
const props = { ...expressionInputProps, executeQuery: spyExecuteQuery };
const wrapper = mount(<ExpressionInput {...props} />);
const btn = wrapper.find(Button).filterWhere((btn) => btn.hasClass('execute-btn'));
btn.simulate('click');
expect(spyExecuteQuery).toHaveBeenCalledTimes(1);
});
});
describe('handleKeyPress', () => {
it('should call executeQuery on Enter key pressed', () => {
const spyExecuteQuery = jest.fn();
const props = { ...expressionInputProps, executeQuery: spyExecuteQuery };
const input = mount(<ExpressionInput {...props} />);
const instance: any = input.instance();
instance.handleKeyPress({ preventDefault: jest.fn, key: 'Enter' });
expect(spyExecuteQuery).toHaveBeenCalled();
});
it('should NOT call executeQuery on Enter + Shift', () => {
const spyExecuteQuery = jest.fn();
const props = { ...expressionInputProps, executeQuery: spyExecuteQuery };
const input = mount(<ExpressionInput {...props} />);
const instance: any = input.instance();
instance.handleKeyPress({ preventDefault: jest.fn, key: 'Enter', shiftKey: true });
expect(spyExecuteQuery).not.toHaveBeenCalled();
});
});
describe('getSearchMatches', () => {
it('should return matched value', () => {
const instance: any = expressionInput.instance();
expect(instance.getSearchMatches('foo', ['barfoobaz', 'bazasdbaz'])).toHaveLength(1);
});
it('should return empty array if no match found', () => {
const instance: any = expressionInput.instance();
expect(instance.getSearchMatches('foo', ['barbaz', 'bazasdbaz'])).toHaveLength(0);
});
});
describe('createAutocompleteSection', () => {
const props = {
...expressionInputProps,
metricNames: ['foo', 'bar', 'baz'],
};
it('should close menu if no matches found', () => {
const input = mount(<ExpressionInput {...props} />);
const instance: any = input.instance();
const spyCloseMenu = jest.fn();
instance.createAutocompleteSection({ inputValue: 'qqqqqq', closeMenu: spyCloseMenu });
setTimeout(() => {
expect(spyCloseMenu).toHaveBeenCalled();
});
});
it('should not render list if inputValue not exist', () => {
const input = mount(<ExpressionInput {...props} />);
const instance: any = input.instance();
const spyCloseMenu = jest.fn();
instance.createAutocompleteSection({ closeMenu: spyCloseMenu });
setTimeout(() => expect(spyCloseMenu).toHaveBeenCalled());
});
it('should not render list if enableAutocomplete is false', () => {
const input = mount(<ExpressionInput {...props} enableAutocomplete={false} />);
const instance: any = input.instance();
const spyCloseMenu = jest.fn();
instance.createAutocompleteSection({ closeMenu: spyCloseMenu });
setTimeout(() => expect(spyCloseMenu).toHaveBeenCalled());
});
it('should render autosuggest-dropdown', () => {
const input = mount(<ExpressionInput {...props} enableAutocomplete={true} />);
const instance: any = input.instance();
const spyGetMenuProps = jest.fn();
const sections = instance.createAutocompleteSection({
inputValue: 'foo',
highlightedIndex: 0,
getMenuProps: spyGetMenuProps,
getItemProps: jest.fn,
});
expect(sections.props.className).toEqual('autosuggest-dropdown');
});
});
describe('when downshift is open', () => {
it('closes the menu on "Enter"', () => {
const downshift = expressionInput.find(Downshift);
const input = downshift.find(Input);
downshift.setState({ isOpen: true });
const event = getKeyEvent('Enter');
input.simulate('keydown', event);
expect(downshift.state('isOpen')).toBe(false);
});
it('should blur input on escape', () => {
const downshift = expressionInput.find(Downshift);
const instance: any = expressionInput.instance();
const spyBlur = jest.spyOn(instance.exprInputRef.current, 'blur');
const input = downshift.find(Input);
downshift.setState({ isOpen: false });
const event = getKeyEvent('Escape');
input.simulate('keydown', event);
expect(spyBlur).toHaveBeenCalled();
});
it('noops on ArrowUp or ArrowDown', () => {
const downshift = expressionInput.find(Downshift);
const input = downshift.find(Input);
downshift.setState({ isOpen: true });
['ArrowUp', 'ArrowDown'].forEach((key) => {
const event = getKeyEvent(key);
input.simulate('keydown', event);
const nativeEvent = event.nativeEvent as any;
expect(nativeEvent.preventDownshiftDefault).toBeUndefined();
});
});
it('does not render an autosuggest if there are no matches', () => {
const downshift = expressionInput.find(Downshift);
downshift.setState({ isOpen: true });
const ul = downshift.find('ul');
expect(ul).toHaveLength(0);
});
it('renders an autosuggest if there are matches', () => {
const downshift = expressionInput.find(Downshift);
downshift.setState({ isOpen: true });
setTimeout(() => {
const ul = downshift.find('ul');
expect(ul.prop('className')).toEqual('card list-group');
const items = ul.find('li');
expect(items.map((item) => item.text()).join(', ')).toEqual(
'node_cpu_guest_seconds_total, node_cpu_seconds_total, instance:node_cpu_utilisation:rate1m'
);
});
});
});
it('renders an execute Button', () => {
it('renders an execute button', () => {
const addon = expressionInput.find(InputGroupAddon).filterWhere((addon) => addon.prop('addonType') === 'append');
const button = addon.find(Button).find('.execute-btn').first();
expect(button.prop('color')).toEqual('primary');
expect(button.text()).toEqual('Execute');
});
it('executes the query when clicking the execute button', () => {
const spyExecuteQuery = jest.fn();
const props = { ...expressionInputProps, executeQuery: spyExecuteQuery };
const wrapper = mount(<ExpressionInput {...props} />);
const btn = wrapper.find(Button).filterWhere((btn) => btn.hasClass('execute-btn'));
btn.simulate('click');
expect(spyExecuteQuery).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,15 +1,30 @@
import React, { Component } from 'react';
import { Button, Input, InputGroup, InputGroupAddon, InputGroupText } from 'reactstrap';
import React, { FC, useState, useEffect, useRef } from 'react';
import { Button, InputGroup, InputGroupAddon, InputGroupText } from 'reactstrap';
import Downshift, { ControllerStateAndHelpers } from 'downshift';
import sanitizeHTML from 'sanitize-html';
import { EditorView, highlightSpecialChars, keymap, ViewUpdate, placeholder } from '@codemirror/view';
import { EditorState, Prec, Compartment } from '@codemirror/state';
import { indentOnInput, syntaxTree } from '@codemirror/language';
import { history, historyKeymap } from '@codemirror/history';
import { defaultKeymap, insertNewlineAndIndent } from '@codemirror/commands';
import { bracketMatching } from '@codemirror/matchbrackets';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/closebrackets';
import { highlightSelectionMatches } from '@codemirror/search';
import { commentKeymap } from '@codemirror/comment';
import { lintKeymap } from '@codemirror/lint';
import { autocompletion, completionKeymap, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { baseTheme, lightTheme, darkTheme, promqlHighlighter } from './CMTheme';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGlobeEurope, faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons';
import { faSearch, faSpinner, faGlobeEurope } from '@fortawesome/free-solid-svg-icons';
import MetricsExplorer from './MetricsExplorer';
import { Fuzzy, FuzzyResult } from '@nexucis/fuzzy';
import { usePathPrefix } from '../../contexts/PathPrefixContext';
import { useTheme } from '../../contexts/ThemeContext';
import { CompleteStrategy, PromQLExtension } from 'codemirror-promql';
import { newCompleteStrategy } from 'codemirror-promql/dist/cjs/complete';
interface ExpressionInputProps {
const promqlExtension = new PromQLExtension();
interface CMExpressionInputProps {
value: string;
onExpressionChange: (expr: string) => void;
queryHistory: string[];
@ -17,235 +32,218 @@ interface ExpressionInputProps {
executeQuery: () => void;
loading: boolean;
enableAutocomplete: boolean;
enableHighlighting: boolean;
enableLinter: boolean;
}
interface ExpressionInputState {
height: number | string;
showMetricsExplorer: boolean;
const dynamicConfigCompartment = new Compartment();
// Autocompletion strategy that wraps the main one and enriches
// it with past query items.
export class HistoryCompleteStrategy implements CompleteStrategy {
private complete: CompleteStrategy;
private queryHistory: string[];
constructor(complete: CompleteStrategy, queryHistory: string[]) {
this.complete = complete;
this.queryHistory = queryHistory;
}
promQL(context: CompletionContext): Promise<CompletionResult | null> | CompletionResult | null {
return Promise.resolve(this.complete.promQL(context)).then((res) => {
const { state, pos } = context;
const tree = syntaxTree(state).resolve(pos, -1);
const start = res != null ? res.from : tree.from;
if (start !== 0) {
return res;
}
const historyItems: CompletionResult = {
from: start,
to: pos,
options: this.queryHistory.map((q) => ({
label: q.length < 80 ? q : q.slice(0, 76).concat('...'),
detail: 'past query',
apply: q,
info: q.length < 80 ? undefined : q,
})),
span: /^[a-zA-Z0-9_:]+$/,
};
if (res !== null) {
historyItems.options = historyItems.options.concat(res.options);
}
return historyItems;
});
}
}
const fuz = new Fuzzy({ pre: '<strong>', post: '</strong>', shouldSort: true });
const ExpressionInput: FC<CMExpressionInputProps> = ({
value,
onExpressionChange,
queryHistory,
metricNames,
executeQuery,
loading,
enableAutocomplete,
enableHighlighting,
enableLinter,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const [showMetricsExplorer, setShowMetricsExplorer] = useState<boolean>(false);
const pathPrefix = usePathPrefix();
const { theme } = useTheme();
class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputState> {
private exprInputRef = React.createRef<HTMLInputElement>();
// (Re)initialize editor based on settings / setting changes.
useEffect(() => {
// Build the dynamic part of the config.
promqlExtension
.activateCompletion(enableAutocomplete)
.activateLinter(enableLinter)
.setComplete({
completeStrategy: new HistoryCompleteStrategy(
newCompleteStrategy({
remote: { url: pathPrefix, cache: { initialMetricList: metricNames } },
}),
queryHistory
),
});
const dynamicConfig = [
enableHighlighting ? promqlHighlighter : [],
promqlExtension.asExtension(),
theme === 'dark' ? darkTheme : lightTheme,
];
constructor(props: ExpressionInputProps) {
super(props);
this.state = {
height: 'auto',
showMetricsExplorer: false,
};
}
// Create or reconfigure the editor.
const view = viewRef.current;
if (view === null) {
// If the editor does not exist yet, create it.
if (!containerRef.current) {
throw new Error('expected CodeMirror container element to exist');
}
componentDidMount(): void {
this.setHeight();
}
const startState = EditorState.create({
doc: value,
extensions: [
baseTheme,
highlightSpecialChars(),
history(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
bracketMatching(),
closeBrackets(),
autocompletion(),
highlightSelectionMatches(),
EditorView.lineWrapping,
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
...commentKeymap,
...completionKeymap,
...lintKeymap,
]),
placeholder('Expression (press Shift+Enter for newlines)'),
dynamicConfigCompartment.of(dynamicConfig),
// This keymap is added without precedence so that closing the autocomplete dropdown
// via Escape works without blurring the editor.
keymap.of([
{
key: 'Escape',
run: (v: EditorView): boolean => {
v.contentDOM.blur();
return false;
},
},
]),
Prec.override(
keymap.of([
{
key: 'Enter',
run: (v: EditorView): boolean => {
executeQuery();
return true;
},
},
{
key: 'Shift-Enter',
run: insertNewlineAndIndent,
},
])
),
EditorView.updateListener.of((update: ViewUpdate): void => {
onExpressionChange(update.state.doc.toString());
}),
],
});
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 });
}
};
const view = new EditorView({
state: startState,
parent: containerRef.current,
});
handleInput = (): void => {
if (this.exprInputRef.current) {
this.setValue(this.exprInputRef.current.value);
}
};
viewRef.current = view;
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<HTMLInputElement>): 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<any>): 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,
<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 slow.
.map((result: FuzzyResult) => {
const itemProps = downshift.getItemProps({
key: result.original,
index,
item: result.original,
style: {
backgroundColor: highlightedIndex === index++ ? 'lightgray' : 'white',
},
});
return (
<li
key={title}
{...itemProps}
dangerouslySetInnerHTML={{ __html: sanitizeHTML(result.rendered, { allowedTags: ['strong'] }) }}
/>
);
})}
</ul>,
];
}, [] 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 (
<div {...downshift.getMenuProps()} className="autosuggest-dropdown">
{sections}
</div>
);
};
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);
view.focus();
} else {
newValue = previousValue + value;
// The editor already exists, just reconfigure the dynamically configured parts.
view.dispatch(
view.state.update({
effects: dynamicConfigCompartment.reconfigure(dynamicConfig),
})
);
}
// "value" is only used in the initial render, so we don't want to
// re-run this effect every time that "value" changes.
//
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enableAutocomplete, enableHighlighting, enableLinter, executeQuery, onExpressionChange, queryHistory, theme]);
this.setValue(newValue);
const insertAtCursor = (value: string) => {
const view = viewRef.current;
if (view === null) {
return;
}
const { from, to } = view.state.selection.ranges[0];
view.dispatch(
view.state.update({
changes: { from, to, insert: value },
})
);
};
render(): JSX.Element {
const { executeQuery, value } = this.props;
const { height } = this.state;
return (
<>
<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.
// 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}
/>
<InputGroupAddon addonType="append">
<Button className="metrics-explorer-btn" title="Open metrics explorer" onClick={this.openMetricsExplorer}>
<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>
return (
<>
<InputGroup className="expression-input">
<InputGroupAddon addonType="prepend">
<InputGroupText>
{loading ? <FontAwesomeIcon icon={faSpinner} spin /> : <FontAwesomeIcon icon={faSearch} />}
</InputGroupText>
</InputGroupAddon>
<div ref={containerRef} className="cm-expression-input" />
<InputGroupAddon addonType="append">
<Button
className="metrics-explorer-btn"
title="Open metrics explorer"
onClick={() => setShowMetricsExplorer(true)}
>
<FontAwesomeIcon icon={faGlobeEurope} />
</Button>
<Button className="execute-btn" color="primary" onClick={executeQuery}>
Execute
</Button>
</InputGroupAddon>
</InputGroup>
<MetricsExplorer
show={this.state.showMetricsExplorer}
updateShow={this.updateShowMetricsExplorer}
metrics={this.props.metricNames}
insertAtCursor={this.insertAtCursor}
/>
</>
);
}
}
<MetricsExplorer
show={showMetricsExplorer}
updateShow={setShowMetricsExplorer}
metrics={metricNames}
insertAtCursor={insertAtCursor}
/>
</>
);
};
export default ExpressionInput;

View file

@ -1,7 +1,6 @@
import * as React from 'react';
import { mount, shallow } from 'enzyme';
import Panel, { PanelOptions, PanelType } from './Panel';
import ExpressionInput from './ExpressionInput';
import GraphControls from './GraphControls';
import { NavLink, TabPane } from 'reactstrap';
import TimeInput from './TimeInput';
@ -38,17 +37,6 @@ const defaultProps = {
describe('Panel', () => {
const panel = shallow(<Panel {...defaultProps} />);
it('renders an ExpressionInput', () => {
const input = panel.find(ExpressionInput);
expect(input.prop('value')).toEqual('prometheus_engine');
expect(input.prop('metricNames')).toEqual([
'prometheus_engine_queries',
'prometheus_engine_queries_concurrent_max',
'prometheus_engine_query_duration_seconds',
]);
expect(input.prop('queryHistory')).toEqual([]);
});
it('renders NavLinks', () => {
const results: PanelOptions[] = [];
const onOptionsChanged = (opts: PanelOptions): void => {

View file

@ -5,7 +5,6 @@ import { Alert, Button, Col, Nav, NavItem, NavLink, Row, TabContent, TabPane } f
import moment from 'moment-timezone';
import ExpressionInput from './ExpressionInput';
import CMExpressionInput from './CMExpressionInput';
import GraphControls from './GraphControls';
import { GraphTabContent } from './GraphTabContent';
import DataTable from './DataTable';
@ -24,7 +23,6 @@ interface PanelProps {
removePanel: () => void;
onExecuteQuery: (query: string) => void;
pathPrefix: string;
useExperimentalEditor: boolean;
enableAutocomplete: boolean;
enableHighlighting: boolean;
enableLinter: boolean;
@ -272,29 +270,17 @@ class Panel extends Component<PanelProps, PanelState> {
<div className="panel">
<Row>
<Col>
{this.props.useExperimentalEditor ? (
<CMExpressionInput
value={this.state.exprInputValue}
onExpressionChange={this.handleExpressionChange}
executeQuery={this.executeQuery}
loading={this.state.loading}
enableAutocomplete={this.props.enableAutocomplete}
enableHighlighting={this.props.enableHighlighting}
enableLinter={this.props.enableLinter}
queryHistory={pastQueries}
metricNames={metricNames}
/>
) : (
<ExpressionInput
value={this.state.exprInputValue}
onExpressionChange={this.handleExpressionChange}
executeQuery={this.executeQuery}
loading={this.state.loading}
enableAutocomplete={this.props.enableAutocomplete}
queryHistory={pastQueries}
metricNames={metricNames}
/>
)}
<ExpressionInput
value={this.state.exprInputValue}
onExpressionChange={this.handleExpressionChange}
executeQuery={this.executeQuery}
loading={this.state.loading}
enableAutocomplete={this.props.enableAutocomplete}
enableHighlighting={this.props.enableHighlighting}
enableLinter={this.props.enableLinter}
queryHistory={pastQueries}
metricNames={metricNames}
/>
</Col>
</Row>
<Row>

View file

@ -11,7 +11,6 @@ describe('PanelList', () => {
{ id: 'use-local-time-checkbox', label: 'Use local time', default: false },
{ id: 'query-history-checkbox', label: 'Enable query history', default: false },
{ id: 'autocomplete-checkbox', label: 'Enable autocomplete', default: true },
{ id: 'use-experimental-editor-checkbox', label: 'Use experimental editor', default: true },
{ id: 'highlighting-checkbox', label: 'Enable highlighting', default: true },
{ id: 'linter-checkbox', label: 'Enable linter', default: true },
].forEach((cb, idx) => {

View file

@ -20,7 +20,6 @@ interface PanelListContentProps {
panels: PanelMeta[];
metrics: string[];
useLocalTime: boolean;
useExperimentalEditor: boolean;
queryHistoryEnabled: boolean;
enableAutocomplete: boolean;
enableHighlighting: boolean;
@ -30,7 +29,6 @@ interface PanelListContentProps {
export const PanelListContent: FC<PanelListContentProps> = ({
metrics = [],
useLocalTime,
useExperimentalEditor,
queryHistoryEnabled,
enableAutocomplete,
enableHighlighting,
@ -105,7 +103,6 @@ export const PanelListContent: FC<PanelListContentProps> = ({
)
)
}
useExperimentalEditor={useExperimentalEditor}
useLocalTime={useLocalTime}
metricNames={metrics}
pastQueries={queryHistoryEnabled ? historyItems : []}
@ -123,7 +120,6 @@ export const PanelListContent: FC<PanelListContentProps> = ({
const PanelList: FC = () => {
const [delta, setDelta] = useState(0);
const [useExperimentalEditor, setUseExperimentalEditor] = useLocalStorage('use-new-editor', true);
const [useLocalTime, setUseLocalTime] = useLocalStorage('use-local-time', false);
const [enableQueryHistory, setEnableQueryHistory] = useLocalStorage('enable-query-history', false);
const [enableAutocomplete, setEnableAutocomplete] = useLocalStorage('enable-metric-autocomplete', true);
@ -180,34 +176,22 @@ const PanelList: FC = () => {
Enable autocomplete
</Checkbox>
</div>
<div className="float-right">
<Checkbox
wrapperStyles={{ display: 'inline-block' }}
id="use-experimental-editor-checkbox"
onChange={({ target }) => setUseExperimentalEditor(target.checked)}
defaultChecked={useExperimentalEditor}
>
Use experimental editor
</Checkbox>
<Checkbox
wrapperStyles={{ marginLeft: 20, display: 'inline-block' }}
id="highlighting-checkbox"
onChange={({ target }) => setEnableHighlighting(target.checked)}
defaultChecked={enableHighlighting}
disabled={!useExperimentalEditor}
>
Enable highlighting
</Checkbox>
<Checkbox
wrapperStyles={{ marginLeft: 20, display: 'inline-block' }}
id="linter-checkbox"
onChange={({ target }) => setEnableLinter(target.checked)}
defaultChecked={enableLinter}
disabled={!useExperimentalEditor}
>
Enable linter
</Checkbox>
</div>
<Checkbox
wrapperStyles={{ marginLeft: 20, display: 'inline-block' }}
id="highlighting-checkbox"
onChange={({ target }) => setEnableHighlighting(target.checked)}
defaultChecked={enableHighlighting}
>
Enable highlighting
</Checkbox>
<Checkbox
wrapperStyles={{ marginLeft: 20, display: 'inline-block' }}
id="linter-checkbox"
onChange={({ target }) => setEnableLinter(target.checked)}
defaultChecked={enableLinter}
>
Enable linter
</Checkbox>
</div>
{(delta > 30 || timeErr) && (
<Alert color="danger">
@ -227,7 +211,6 @@ const PanelList: FC = () => {
panels={decodePanelOptionsFromQueryString(window.location.search)}
useLocalTime={useLocalTime}
metrics={metricsRes.data}
useExperimentalEditor={useExperimentalEditor}
queryHistoryEnabled={enableQueryHistory}
enableAutocomplete={enableAutocomplete}
enableHighlighting={enableHighlighting}