mirror of
https://github.com/prometheus/prometheus.git
synced 2024-11-12 16:44:05 -08:00
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:
parent
703d9bcd56
commit
a4ad290987
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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;
|
|
|
@ -1,26 +1,15 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { mount, ReactWrapper } from 'enzyme';
|
import { mount, ReactWrapper } from 'enzyme';
|
||||||
import ExpressionInput from './ExpressionInput';
|
import ExpressionInput from './ExpressionInput';
|
||||||
import Downshift from 'downshift';
|
import { Button, InputGroup, InputGroupAddon } from 'reactstrap';
|
||||||
import { Button, InputGroup, InputGroupAddon, Input } from 'reactstrap';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faSearch, faSpinner } from '@fortawesome/free-solid-svg-icons';
|
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', () => {
|
describe('ExpressionInput', () => {
|
||||||
const metricNames = ['instance:node_cpu_utilisation:rate1m', 'node_cpu_guest_seconds_total', 'node_cpu_seconds_total'];
|
|
||||||
const expressionInputProps = {
|
const expressionInputProps = {
|
||||||
value: 'node_cpu',
|
value: 'node_cpu',
|
||||||
queryHistory: [],
|
queryHistory: [],
|
||||||
metricNames,
|
metricNames: [],
|
||||||
executeQuery: (): void => {
|
executeQuery: (): void => {
|
||||||
// Do nothing.
|
// Do nothing.
|
||||||
},
|
},
|
||||||
|
@ -29,6 +18,8 @@ describe('ExpressionInput', () => {
|
||||||
},
|
},
|
||||||
loading: false,
|
loading: false,
|
||||||
enableAutocomplete: true,
|
enableAutocomplete: true,
|
||||||
|
enableHighlighting: true,
|
||||||
|
enableLinter: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let expressionInput: ReactWrapper;
|
let expressionInput: ReactWrapper;
|
||||||
|
@ -36,11 +27,6 @@ describe('ExpressionInput', () => {
|
||||||
expressionInput = mount(<ExpressionInput {...expressionInputProps} />);
|
expressionInput = mount(<ExpressionInput {...expressionInputProps} />);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a downshift component', () => {
|
|
||||||
const downshift = expressionInput.find(Downshift);
|
|
||||||
expect(downshift).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders an InputGroup', () => {
|
it('renders an InputGroup', () => {
|
||||||
const inputGroup = expressionInput.find(InputGroup);
|
const inputGroup = expressionInput.find(InputGroup);
|
||||||
expect(inputGroup.prop('className')).toEqual('expression-input');
|
expect(inputGroup.prop('className')).toEqual('expression-input');
|
||||||
|
@ -60,65 +46,19 @@ describe('ExpressionInput', () => {
|
||||||
expect(icon.prop('spin')).toBe(true);
|
expect(icon.prop('spin')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders an Input', () => {
|
it('renders a CodeMirror expression input', () => {
|
||||||
const input = expressionInput.find(Input);
|
const input = expressionInput.find('div.cm-expression-input');
|
||||||
expect(input.prop('style')).toEqual({ height: 0 });
|
expect(input.text()).toContain('node_cpu');
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when autosuggest is closed', () => {
|
it('renders an execute button', () => {
|
||||||
it('prevents Downshift default on Home, End, Arrows', () => {
|
const addon = expressionInput.find(InputGroupAddon).filterWhere((addon) => addon.prop('addonType') === 'append');
|
||||||
const downshift = expressionInput.find(Downshift);
|
const button = addon.find(Button).find('.execute-btn').first();
|
||||||
const input = downshift.find(Input);
|
expect(button.prop('color')).toEqual('primary');
|
||||||
downshift.setState({ isOpen: false });
|
expect(button.text()).toEqual('Execute');
|
||||||
['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', () => {
|
it('executes the query when clicking the execute button', () => {
|
||||||
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 spyExecuteQuery = jest.fn();
|
||||||
const props = { ...expressionInputProps, executeQuery: spyExecuteQuery };
|
const props = { ...expressionInputProps, executeQuery: spyExecuteQuery };
|
||||||
const wrapper = mount(<ExpressionInput {...props} />);
|
const wrapper = mount(<ExpressionInput {...props} />);
|
||||||
|
@ -127,138 +67,3 @@ describe('ExpressionInput', () => {
|
||||||
expect(spyExecuteQuery).toHaveBeenCalledTimes(1);
|
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', () => {
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
@ -1,15 +1,30 @@
|
||||||
import React, { Component } from 'react';
|
import React, { FC, useState, useEffect, useRef } from 'react';
|
||||||
import { Button, Input, InputGroup, InputGroupAddon, InputGroupText } from 'reactstrap';
|
import { Button, InputGroup, InputGroupAddon, InputGroupText } from 'reactstrap';
|
||||||
|
|
||||||
import Downshift, { ControllerStateAndHelpers } from 'downshift';
|
import { EditorView, highlightSpecialChars, keymap, ViewUpdate, placeholder } from '@codemirror/view';
|
||||||
import sanitizeHTML from 'sanitize-html';
|
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 { 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 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;
|
value: string;
|
||||||
onExpressionChange: (expr: string) => void;
|
onExpressionChange: (expr: string) => void;
|
||||||
queryHistory: string[];
|
queryHistory: string[];
|
||||||
|
@ -17,235 +32,218 @@ interface ExpressionInputProps {
|
||||||
executeQuery: () => void;
|
executeQuery: () => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
enableAutocomplete: boolean;
|
enableAutocomplete: boolean;
|
||||||
|
enableHighlighting: boolean;
|
||||||
|
enableLinter: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExpressionInputState {
|
const dynamicConfigCompartment = new Compartment();
|
||||||
height: number | string;
|
|
||||||
showMetricsExplorer: boolean;
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fuz = new Fuzzy({ pre: '<strong>', post: '</strong>', shouldSort: true });
|
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;
|
||||||
|
|
||||||
class ExpressionInput extends Component<ExpressionInputProps, ExpressionInputState> {
|
if (start !== 0) {
|
||||||
private exprInputRef = React.createRef<HTMLInputElement>();
|
return res;
|
||||||
|
|
||||||
constructor(props: ExpressionInputProps) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
height: 'auto',
|
|
||||||
showMetricsExplorer: false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount(): void {
|
const historyItems: CompletionResult = {
|
||||||
this.setHeight();
|
from: start,
|
||||||
}
|
to: pos,
|
||||||
|
options: this.queryHistory.map((q) => ({
|
||||||
setHeight = (): void => {
|
label: q.length < 80 ? q : q.slice(0, 76).concat('...'),
|
||||||
if (this.exprInputRef.current) {
|
detail: 'past query',
|
||||||
const { offsetHeight, clientHeight, scrollHeight } = this.exprInputRef.current;
|
apply: q,
|
||||||
const offset = offsetHeight - clientHeight; // Needed in order for the height to be more accurate.
|
info: q.length < 80 ? undefined : q,
|
||||||
this.setState({ height: scrollHeight + offset });
|
})),
|
||||||
}
|
span: /^[a-zA-Z0-9_:]+$/,
|
||||||
};
|
};
|
||||||
|
|
||||||
handleInput = (): void => {
|
if (res !== null) {
|
||||||
if (this.exprInputRef.current) {
|
historyItems.options = historyItems.options.concat(res.options);
|
||||||
this.setValue(this.exprInputRef.current.value);
|
|
||||||
}
|
}
|
||||||
};
|
return historyItems;
|
||||||
|
|
||||||
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}
|
const ExpressionInput: FC<CMExpressionInputProps> = ({
|
||||||
dangerouslySetInnerHTML={{ __html: sanitizeHTML(result.rendered, { allowedTags: ['strong'] }) }}
|
value,
|
||||||
/>
|
onExpressionChange,
|
||||||
);
|
queryHistory,
|
||||||
})}
|
metricNames,
|
||||||
</ul>,
|
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,
|
||||||
];
|
];
|
||||||
}, [] as JSX.Element[])
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (!sections.length) {
|
// Create or reconfigure the editor.
|
||||||
// This is ugly but is needed in order to sync state updates.
|
const view = viewRef.current;
|
||||||
// This way we force downshift to wait React render call to complete before closeMenu to be triggered.
|
if (view === null) {
|
||||||
setTimeout(closeMenu);
|
// If the editor does not exist yet, create it.
|
||||||
return null;
|
if (!containerRef.current) {
|
||||||
|
throw new Error('expected CodeMirror container element to exist');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const startState = EditorState.create({
|
||||||
<div {...downshift.getMenuProps()} className="autosuggest-dropdown">
|
doc: value,
|
||||||
{sections}
|
extensions: [
|
||||||
</div>
|
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 },
|
||||||
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Downshift onSelect={this.setValue}>
|
|
||||||
{(downshift) => (
|
|
||||||
<div>
|
|
||||||
<InputGroup className="expression-input">
|
<InputGroup className="expression-input">
|
||||||
<InputGroupAddon addonType="prepend">
|
<InputGroupAddon addonType="prepend">
|
||||||
<InputGroupText>
|
<InputGroupText>
|
||||||
{this.props.loading ? <FontAwesomeIcon icon={faSpinner} spin /> : <FontAwesomeIcon icon={faSearch} />}
|
{loading ? <FontAwesomeIcon icon={faSpinner} spin /> : <FontAwesomeIcon icon={faSearch} />}
|
||||||
</InputGroupText>
|
</InputGroupText>
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
<Input
|
<div ref={containerRef} className="cm-expression-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">
|
<InputGroupAddon addonType="append">
|
||||||
<Button className="metrics-explorer-btn" title="Open metrics explorer" onClick={this.openMetricsExplorer}>
|
<Button
|
||||||
|
className="metrics-explorer-btn"
|
||||||
|
title="Open metrics explorer"
|
||||||
|
onClick={() => setShowMetricsExplorer(true)}
|
||||||
|
>
|
||||||
<FontAwesomeIcon icon={faGlobeEurope} />
|
<FontAwesomeIcon icon={faGlobeEurope} />
|
||||||
</Button>
|
</Button>
|
||||||
</InputGroupAddon>
|
|
||||||
<InputGroupAddon addonType="append">
|
|
||||||
<Button className="execute-btn" color="primary" onClick={executeQuery}>
|
<Button className="execute-btn" color="primary" onClick={executeQuery}>
|
||||||
Execute
|
Execute
|
||||||
</Button>
|
</Button>
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
{downshift.isOpen && this.createAutocompleteSection(downshift)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Downshift>
|
|
||||||
|
|
||||||
<MetricsExplorer
|
<MetricsExplorer
|
||||||
show={this.state.showMetricsExplorer}
|
show={showMetricsExplorer}
|
||||||
updateShow={this.updateShowMetricsExplorer}
|
updateShow={setShowMetricsExplorer}
|
||||||
metrics={this.props.metricNames}
|
metrics={metricNames}
|
||||||
insertAtCursor={this.insertAtCursor}
|
insertAtCursor={insertAtCursor}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default ExpressionInput;
|
export default ExpressionInput;
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { mount, shallow } from 'enzyme';
|
import { mount, shallow } from 'enzyme';
|
||||||
import Panel, { PanelOptions, PanelType } from './Panel';
|
import Panel, { PanelOptions, PanelType } from './Panel';
|
||||||
import ExpressionInput from './ExpressionInput';
|
|
||||||
import GraphControls from './GraphControls';
|
import GraphControls from './GraphControls';
|
||||||
import { NavLink, TabPane } from 'reactstrap';
|
import { NavLink, TabPane } from 'reactstrap';
|
||||||
import TimeInput from './TimeInput';
|
import TimeInput from './TimeInput';
|
||||||
|
@ -38,17 +37,6 @@ const defaultProps = {
|
||||||
describe('Panel', () => {
|
describe('Panel', () => {
|
||||||
const panel = shallow(<Panel {...defaultProps} />);
|
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', () => {
|
it('renders NavLinks', () => {
|
||||||
const results: PanelOptions[] = [];
|
const results: PanelOptions[] = [];
|
||||||
const onOptionsChanged = (opts: PanelOptions): void => {
|
const onOptionsChanged = (opts: PanelOptions): void => {
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { Alert, Button, Col, Nav, NavItem, NavLink, Row, TabContent, TabPane } f
|
||||||
import moment from 'moment-timezone';
|
import moment from 'moment-timezone';
|
||||||
|
|
||||||
import ExpressionInput from './ExpressionInput';
|
import ExpressionInput from './ExpressionInput';
|
||||||
import CMExpressionInput from './CMExpressionInput';
|
|
||||||
import GraphControls from './GraphControls';
|
import GraphControls from './GraphControls';
|
||||||
import { GraphTabContent } from './GraphTabContent';
|
import { GraphTabContent } from './GraphTabContent';
|
||||||
import DataTable from './DataTable';
|
import DataTable from './DataTable';
|
||||||
|
@ -24,7 +23,6 @@ interface PanelProps {
|
||||||
removePanel: () => void;
|
removePanel: () => void;
|
||||||
onExecuteQuery: (query: string) => void;
|
onExecuteQuery: (query: string) => void;
|
||||||
pathPrefix: string;
|
pathPrefix: string;
|
||||||
useExperimentalEditor: boolean;
|
|
||||||
enableAutocomplete: boolean;
|
enableAutocomplete: boolean;
|
||||||
enableHighlighting: boolean;
|
enableHighlighting: boolean;
|
||||||
enableLinter: boolean;
|
enableLinter: boolean;
|
||||||
|
@ -272,8 +270,7 @@ class Panel extends Component<PanelProps, PanelState> {
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<Row>
|
<Row>
|
||||||
<Col>
|
<Col>
|
||||||
{this.props.useExperimentalEditor ? (
|
<ExpressionInput
|
||||||
<CMExpressionInput
|
|
||||||
value={this.state.exprInputValue}
|
value={this.state.exprInputValue}
|
||||||
onExpressionChange={this.handleExpressionChange}
|
onExpressionChange={this.handleExpressionChange}
|
||||||
executeQuery={this.executeQuery}
|
executeQuery={this.executeQuery}
|
||||||
|
@ -284,17 +281,6 @@ class Panel extends Component<PanelProps, PanelState> {
|
||||||
queryHistory={pastQueries}
|
queryHistory={pastQueries}
|
||||||
metricNames={metricNames}
|
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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Row>
|
<Row>
|
||||||
|
|
|
@ -11,7 +11,6 @@ describe('PanelList', () => {
|
||||||
{ id: 'use-local-time-checkbox', label: 'Use local time', default: false },
|
{ id: 'use-local-time-checkbox', label: 'Use local time', default: false },
|
||||||
{ id: 'query-history-checkbox', label: 'Enable query history', default: false },
|
{ id: 'query-history-checkbox', label: 'Enable query history', default: false },
|
||||||
{ id: 'autocomplete-checkbox', label: 'Enable autocomplete', default: true },
|
{ 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: 'highlighting-checkbox', label: 'Enable highlighting', default: true },
|
||||||
{ id: 'linter-checkbox', label: 'Enable linter', default: true },
|
{ id: 'linter-checkbox', label: 'Enable linter', default: true },
|
||||||
].forEach((cb, idx) => {
|
].forEach((cb, idx) => {
|
||||||
|
|
|
@ -20,7 +20,6 @@ interface PanelListContentProps {
|
||||||
panels: PanelMeta[];
|
panels: PanelMeta[];
|
||||||
metrics: string[];
|
metrics: string[];
|
||||||
useLocalTime: boolean;
|
useLocalTime: boolean;
|
||||||
useExperimentalEditor: boolean;
|
|
||||||
queryHistoryEnabled: boolean;
|
queryHistoryEnabled: boolean;
|
||||||
enableAutocomplete: boolean;
|
enableAutocomplete: boolean;
|
||||||
enableHighlighting: boolean;
|
enableHighlighting: boolean;
|
||||||
|
@ -30,7 +29,6 @@ interface PanelListContentProps {
|
||||||
export const PanelListContent: FC<PanelListContentProps> = ({
|
export const PanelListContent: FC<PanelListContentProps> = ({
|
||||||
metrics = [],
|
metrics = [],
|
||||||
useLocalTime,
|
useLocalTime,
|
||||||
useExperimentalEditor,
|
|
||||||
queryHistoryEnabled,
|
queryHistoryEnabled,
|
||||||
enableAutocomplete,
|
enableAutocomplete,
|
||||||
enableHighlighting,
|
enableHighlighting,
|
||||||
|
@ -105,7 +103,6 @@ export const PanelListContent: FC<PanelListContentProps> = ({
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
useExperimentalEditor={useExperimentalEditor}
|
|
||||||
useLocalTime={useLocalTime}
|
useLocalTime={useLocalTime}
|
||||||
metricNames={metrics}
|
metricNames={metrics}
|
||||||
pastQueries={queryHistoryEnabled ? historyItems : []}
|
pastQueries={queryHistoryEnabled ? historyItems : []}
|
||||||
|
@ -123,7 +120,6 @@ export const PanelListContent: FC<PanelListContentProps> = ({
|
||||||
|
|
||||||
const PanelList: FC = () => {
|
const PanelList: FC = () => {
|
||||||
const [delta, setDelta] = useState(0);
|
const [delta, setDelta] = useState(0);
|
||||||
const [useExperimentalEditor, setUseExperimentalEditor] = useLocalStorage('use-new-editor', true);
|
|
||||||
const [useLocalTime, setUseLocalTime] = useLocalStorage('use-local-time', false);
|
const [useLocalTime, setUseLocalTime] = useLocalStorage('use-local-time', false);
|
||||||
const [enableQueryHistory, setEnableQueryHistory] = useLocalStorage('enable-query-history', false);
|
const [enableQueryHistory, setEnableQueryHistory] = useLocalStorage('enable-query-history', false);
|
||||||
const [enableAutocomplete, setEnableAutocomplete] = useLocalStorage('enable-metric-autocomplete', true);
|
const [enableAutocomplete, setEnableAutocomplete] = useLocalStorage('enable-metric-autocomplete', true);
|
||||||
|
@ -180,21 +176,11 @@ const PanelList: FC = () => {
|
||||||
Enable autocomplete
|
Enable autocomplete
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</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
|
<Checkbox
|
||||||
wrapperStyles={{ marginLeft: 20, display: 'inline-block' }}
|
wrapperStyles={{ marginLeft: 20, display: 'inline-block' }}
|
||||||
id="highlighting-checkbox"
|
id="highlighting-checkbox"
|
||||||
onChange={({ target }) => setEnableHighlighting(target.checked)}
|
onChange={({ target }) => setEnableHighlighting(target.checked)}
|
||||||
defaultChecked={enableHighlighting}
|
defaultChecked={enableHighlighting}
|
||||||
disabled={!useExperimentalEditor}
|
|
||||||
>
|
>
|
||||||
Enable highlighting
|
Enable highlighting
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
@ -203,12 +189,10 @@ const PanelList: FC = () => {
|
||||||
id="linter-checkbox"
|
id="linter-checkbox"
|
||||||
onChange={({ target }) => setEnableLinter(target.checked)}
|
onChange={({ target }) => setEnableLinter(target.checked)}
|
||||||
defaultChecked={enableLinter}
|
defaultChecked={enableLinter}
|
||||||
disabled={!useExperimentalEditor}
|
|
||||||
>
|
>
|
||||||
Enable linter
|
Enable linter
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{(delta > 30 || timeErr) && (
|
{(delta > 30 || timeErr) && (
|
||||||
<Alert color="danger">
|
<Alert color="danger">
|
||||||
<strong>Warning: </strong>
|
<strong>Warning: </strong>
|
||||||
|
@ -227,7 +211,6 @@ const PanelList: FC = () => {
|
||||||
panels={decodePanelOptionsFromQueryString(window.location.search)}
|
panels={decodePanelOptionsFromQueryString(window.location.search)}
|
||||||
useLocalTime={useLocalTime}
|
useLocalTime={useLocalTime}
|
||||||
metrics={metricsRes.data}
|
metrics={metricsRes.data}
|
||||||
useExperimentalEditor={useExperimentalEditor}
|
|
||||||
queryHistoryEnabled={enableQueryHistory}
|
queryHistoryEnabled={enableQueryHistory}
|
||||||
enableAutocomplete={enableAutocomplete}
|
enableAutocomplete={enableAutocomplete}
|
||||||
enableHighlighting={enableHighlighting}
|
enableHighlighting={enableHighlighting}
|
||||||
|
|
Loading…
Reference in a new issue