From a4ad2909878a4aad20ddb9e24425cdf8e565008b Mon Sep 17 00:00:00 2001 From: Augustin Husson Date: Mon, 18 Oct 2021 17:22:23 +0200 Subject: [PATCH] remove old promql editor (#9452) * remove old promql editor Signed-off-by: Augustin Husson * rename CMExpression by Expression Signed-off-by: Augustin Husson --- .../pages/graph/CMExpressionInput.test.tsx | 69 --- .../src/pages/graph/CMExpressionInput.tsx | 249 ---------- .../src/pages/graph/ExpressionInput.test.tsx | 229 +-------- .../src/pages/graph/ExpressionInput.tsx | 440 +++++++++--------- .../react-app/src/pages/graph/Panel.test.tsx | 12 - web/ui/react-app/src/pages/graph/Panel.tsx | 36 +- .../src/pages/graph/PanelList.test.tsx | 1 - .../react-app/src/pages/graph/PanelList.tsx | 49 +- 8 files changed, 263 insertions(+), 822 deletions(-) delete mode 100644 web/ui/react-app/src/pages/graph/CMExpressionInput.test.tsx delete mode 100644 web/ui/react-app/src/pages/graph/CMExpressionInput.tsx diff --git a/web/ui/react-app/src/pages/graph/CMExpressionInput.test.tsx b/web/ui/react-app/src/pages/graph/CMExpressionInput.test.tsx deleted file mode 100644 index 9d46b9e2c..000000000 --- a/web/ui/react-app/src/pages/graph/CMExpressionInput.test.tsx +++ /dev/null @@ -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(); - }); - - 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(); - 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(); - const btn = wrapper.find(Button).filterWhere((btn) => btn.hasClass('execute-btn')); - btn.simulate('click'); - expect(spyExecuteQuery).toHaveBeenCalledTimes(1); - }); -}); diff --git a/web/ui/react-app/src/pages/graph/CMExpressionInput.tsx b/web/ui/react-app/src/pages/graph/CMExpressionInput.tsx deleted file mode 100644 index d85feb263..000000000 --- a/web/ui/react-app/src/pages/graph/CMExpressionInput.tsx +++ /dev/null @@ -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 { - 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 = ({ - value, - onExpressionChange, - queryHistory, - metricNames, - executeQuery, - loading, - enableAutocomplete, - enableHighlighting, - enableLinter, -}) => { - const containerRef = useRef(null); - const viewRef = useRef(null); - const [showMetricsExplorer, setShowMetricsExplorer] = useState(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 ( - <> - - - - {loading ? : } - - -
- - - - - - - - - ); -}; - -export default CMExpressionInput; diff --git a/web/ui/react-app/src/pages/graph/ExpressionInput.test.tsx b/web/ui/react-app/src/pages/graph/ExpressionInput.test.tsx index f78a2eb7d..29a98c308 100644 --- a/web/ui/react-app/src/pages/graph/ExpressionInput.test.tsx +++ b/web/ui/react-app/src/pages/graph/ExpressionInput.test.tsx @@ -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 => - ({ - key, - nativeEvent: {}, - preventDefault: () => { - // Do nothing. - }, - } as React.KeyboardEvent); - 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(); }); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); + const btn = wrapper.find(Button).filterWhere((btn) => btn.hasClass('execute-btn')); + btn.simulate('click'); + expect(spyExecuteQuery).toHaveBeenCalledTimes(1); + }); }); diff --git a/web/ui/react-app/src/pages/graph/ExpressionInput.tsx b/web/ui/react-app/src/pages/graph/ExpressionInput.tsx index 5fde8ea31..f45a5315d 100644 --- a/web/ui/react-app/src/pages/graph/ExpressionInput.tsx +++ b/web/ui/react-app/src/pages/graph/ExpressionInput.tsx @@ -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 { + 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: '', post: '', shouldSort: true }); +const ExpressionInput: FC = ({ + value, + onExpressionChange, + queryHistory, + metricNames, + executeQuery, + loading, + enableAutocomplete, + enableHighlighting, + enableLinter, +}) => { + const containerRef = useRef(null); + const viewRef = useRef(null); + const [showMetricsExplorer, setShowMetricsExplorer] = useState(false); + const pathPrefix = usePathPrefix(); + const { theme } = useTheme(); -class ExpressionInput extends Component { - private exprInputRef = React.createRef(); + // (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): void => { - const { executeQuery } = this.props; - if (event.key === 'Enter' && !event.shiftKey) { - executeQuery(); - event.preventDefault(); - } - }; - - getSearchMatches = (input: string, expressions: string[]): FuzzyResult[] => { - return fuz.filter(input.replace(/ /g, ''), expressions); - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - createAutocompleteSection = (downshift: ControllerStateAndHelpers): JSX.Element | null => { - const { inputValue = '', closeMenu, highlightedIndex } = downshift; - const autocompleteSections = { - 'Query History': this.props.queryHistory, - 'Metric Names': this.props.metricNames, - }; - let index = 0; - const sections = - inputValue?.length && this.props.enableAutocomplete - ? Object.entries(autocompleteSections).reduce((acc, [title, items]) => { - const matches = this.getSearchMatches(inputValue, items); - return !matches.length - ? acc - : [ - ...acc, -
    -
  • {title}
  • - {matches - .slice(0, 100) // Limit DOM rendering to 100 results, as DOM rendering is slow. - .map((result: FuzzyResult) => { - const itemProps = downshift.getItemProps({ - key: result.original, - index, - item: result.original, - style: { - backgroundColor: highlightedIndex === index++ ? 'lightgray' : 'white', - }, - }); - return ( -
  • - ); - })} -
, - ]; - }, [] as JSX.Element[]) - : []; - - if (!sections.length) { - // This is ugly but is needed in order to sync state updates. - // This way we force downshift to wait React render call to complete before closeMenu to be triggered. - setTimeout(closeMenu); - return null; - } - - return ( -
- {sections} -
- ); - }; - - openMetricsExplorer = (): void => { - this.setState({ - showMetricsExplorer: true, - }); - }; - - updateShowMetricsExplorer = (show: boolean): void => { - this.setState({ - showMetricsExplorer: show, - }); - }; - - insertAtCursor = (value: string): void => { - if (!this.exprInputRef.current) return; - - const startPosition = this.exprInputRef.current.selectionStart; - const endPosition = this.exprInputRef.current.selectionEnd; - - const previousValue = this.exprInputRef.current.value; - let newValue: string; - if (startPosition && endPosition) { - newValue = - previousValue.substring(0, startPosition) + value + previousValue.substring(endPosition, previousValue.length); + 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) => ( -
- - - - {this.props.loading ? : } - - - { - switch (event.key) { - case 'Home': - case 'End': - // We want to be able to jump to the beginning/end of the input field. - // By default, Downshift otherwise jumps to the first/last suggestion item instead. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (event.nativeEvent as any).preventDownshiftDefault = true; - break; - case 'ArrowUp': - case 'ArrowDown': - if (!downshift.isOpen) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (event.nativeEvent as any).preventDownshiftDefault = true; - } - break; - case 'Enter': - downshift.closeMenu(); - break; - case 'Escape': - if (!downshift.isOpen && this.exprInputRef.current) { - this.exprInputRef.current.blur(); - } - break; - default: - } - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any)} - value={value} - /> - - - - - - - - {downshift.isOpen && this.createAutocompleteSection(downshift)} -
- )} -
+ return ( + <> + + + + {loading ? : } + + +
+ + + + + - - - ); - } -} + + + ); +}; export default ExpressionInput; diff --git a/web/ui/react-app/src/pages/graph/Panel.test.tsx b/web/ui/react-app/src/pages/graph/Panel.test.tsx index 2d702b6fb..328a8967f 100644 --- a/web/ui/react-app/src/pages/graph/Panel.test.tsx +++ b/web/ui/react-app/src/pages/graph/Panel.test.tsx @@ -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(); - 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 => { diff --git a/web/ui/react-app/src/pages/graph/Panel.tsx b/web/ui/react-app/src/pages/graph/Panel.tsx index 1dca06ad0..1cb0c1fde 100644 --- a/web/ui/react-app/src/pages/graph/Panel.tsx +++ b/web/ui/react-app/src/pages/graph/Panel.tsx @@ -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 {
- {this.props.useExperimentalEditor ? ( - - ) : ( - - )} + diff --git a/web/ui/react-app/src/pages/graph/PanelList.test.tsx b/web/ui/react-app/src/pages/graph/PanelList.test.tsx index 196ac3404..e7f75f1ed 100755 --- a/web/ui/react-app/src/pages/graph/PanelList.test.tsx +++ b/web/ui/react-app/src/pages/graph/PanelList.test.tsx @@ -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) => { diff --git a/web/ui/react-app/src/pages/graph/PanelList.tsx b/web/ui/react-app/src/pages/graph/PanelList.tsx index 026e12329..e7fb7ceb0 100644 --- a/web/ui/react-app/src/pages/graph/PanelList.tsx +++ b/web/ui/react-app/src/pages/graph/PanelList.tsx @@ -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 = ({ metrics = [], useLocalTime, - useExperimentalEditor, queryHistoryEnabled, enableAutocomplete, enableHighlighting, @@ -105,7 +103,6 @@ export const PanelListContent: FC = ({ ) ) } - useExperimentalEditor={useExperimentalEditor} useLocalTime={useLocalTime} metricNames={metrics} pastQueries={queryHistoryEnabled ? historyItems : []} @@ -123,7 +120,6 @@ export const PanelListContent: FC = ({ 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
-
- setUseExperimentalEditor(target.checked)} - defaultChecked={useExperimentalEditor} - > - Use experimental editor - - setEnableHighlighting(target.checked)} - defaultChecked={enableHighlighting} - disabled={!useExperimentalEditor} - > - Enable highlighting - - setEnableLinter(target.checked)} - defaultChecked={enableLinter} - disabled={!useExperimentalEditor} - > - Enable linter - -
+ setEnableHighlighting(target.checked)} + defaultChecked={enableHighlighting} + > + Enable highlighting + + setEnableLinter(target.checked)} + defaultChecked={enableLinter} + > + Enable linter +
{(delta > 30 || timeErr) && ( @@ -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}