Add new codemirror-promql-based expression editor (#8634)
* Add new codemirror-promql-based expression editor This adds advanced autocompletion, syntax highlighting, and linting for PromQL. Fixes https://github.com/prometheus/prometheus/issues/6160 Fixes https://github.com/prometheus/prometheus/issues/5421 Signed-off-by: Julius Volz <julius.volz@gmail.com> * Group new editor options and float them left Signed-off-by: Julius Volz <julius.volz@gmail.com> * Improve history autocompletion handling Signed-off-by: Julius Volz <julius.volz@gmail.com> * Only show info tooltips for unabbreviated completion items Signed-off-by: Julius Volz <julius.volz@gmail.com> * Rename "new editor" to "experimental editor" Signed-off-by: Julius Volz <julius.volz@gmail.com> * Add path prefix support Signed-off-by: Julius Volz <julius.volz@gmail.com> * Revert accidental check-in of go.sum changes Signed-off-by: Julius Volz <julius.volz@gmail.com> * Remove spurious console.log Signed-off-by: Julius Volz <julius.volz@gmail.com> * Fix completion item type icon styling Signed-off-by: Julius Volz <julius.volz@gmail.com>pull/8639/head
parent
c7e525bc6b
commit
faacb619c0
Binary file not shown.
@ -0,0 +1,72 @@ |
||||
import * as React from 'react'; |
||||
import { mount, ReactWrapper } from 'enzyme'; |
||||
import CMExpressionInput from './CMExpressionInput'; |
||||
import { Button, InputGroup, InputGroupAddon, Input } 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); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,240 @@ |
||||
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 { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; |
||||
import { commentKeymap } from '@codemirror/comment'; |
||||
import { lintKeymap } from '@codemirror/lint'; |
||||
import { PromQLExtension } from 'codemirror-promql'; |
||||
import { autocompletion, completionKeymap, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; |
||||
import { theme, promqlHighlighter } from './CMTheme'; |
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; |
||||
import { faSearch, faSpinner, faGlobeEurope } from '@fortawesome/free-solid-svg-icons'; |
||||
import MetricsExplorer from './MetricsExplorer'; |
||||
import { CompleteStrategy, newCompleteStrategy } from 'codemirror-promql/complete'; |
||||
import { usePathPrefix } from '../../contexts/PathPrefixContext'; |
||||
|
||||
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(); |
||||
|
||||
// (Re)initialize editor based on settings / setting changes.
|
||||
useEffect(() => { |
||||
// Build the dynamic part of the config.
|
||||
promqlExtension.activateCompletion(enableAutocomplete); |
||||
promqlExtension.activateLinter(enableLinter); |
||||
promqlExtension.setComplete({ |
||||
completeStrategy: new HistoryCompleteStrategy( |
||||
newCompleteStrategy({ |
||||
remote: { url: pathPrefix }, |
||||
}), |
||||
queryHistory |
||||
), |
||||
}); |
||||
const dynamicConfig = [enableHighlighting ? promqlHighlighter : [], promqlExtension.asExtension()]; |
||||
|
||||
// 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: [ |
||||
theme, |
||||
highlightSpecialChars(), |
||||
history(), |
||||
EditorState.allowMultipleSelections.of(true), |
||||
indentOnInput(), |
||||
bracketMatching(), |
||||
closeBrackets(), |
||||
autocompletion(), |
||||
highlightSelectionMatches(), |
||||
keymap.of([ |
||||
...closeBracketsKeymap, |
||||
...defaultKeymap, |
||||
...searchKeymap, |
||||
...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]); |
||||
|
||||
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="btn-light border" title="Open metrics explorer" onClick={() => setShowMetricsExplorer(true)}> |
||||
<FontAwesomeIcon icon={faGlobeEurope} /> |
||||
</Button> |
||||
</InputGroupAddon> |
||||
<InputGroupAddon addonType="append"> |
||||
<Button className="execute-btn" color="primary" onClick={executeQuery}> |
||||
Execute |
||||
</Button> |
||||
</InputGroupAddon> |
||||
</InputGroup> |
||||
|
||||
<MetricsExplorer |
||||
show={showMetricsExplorer} |
||||
updateShow={setShowMetricsExplorer} |
||||
metrics={metricNames} |
||||
insertAtCursor={insertAtCursor} |
||||
/> |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
export default CMExpressionInput; |
||||
@ -0,0 +1,183 @@ |
||||
import { HighlightStyle, tags } from '@codemirror/highlight'; |
||||
import { EditorView } from '@codemirror/view'; |
||||
|
||||
export const theme = EditorView.theme({ |
||||
'&': { |
||||
'&.cm-focused': { |
||||
outline: 'none', |
||||
outline_fallback: 'none', |
||||
}, |
||||
}, |
||||
'.cm-scroller': { |
||||
overflow: 'hidden', |
||||
fontFamily: '"DejaVu Sans Mono", monospace', |
||||
}, |
||||
'.cm-placeholder': { |
||||
fontFamily: |
||||
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"', |
||||
}, |
||||
|
||||
'.cm-matchingBracket': { |
||||
color: '#000', |
||||
backgroundColor: '#dedede', |
||||
fontWeight: 'bold', |
||||
outline: '1px dashed transparent', |
||||
}, |
||||
'.cm-nonmatchingBracket': { borderColor: 'red' }, |
||||
|
||||
'.cm-tooltip': { |
||||
backgroundColor: '#f8f8f8', |
||||
borderColor: 'rgba(52, 79, 113, 0.2)', |
||||
}, |
||||
|
||||
'.cm-tooltip.cm-tooltip-autocomplete': { |
||||
'& > ul': { |
||||
maxHeight: '350px', |
||||
fontFamily: '"DejaVu Sans Mono", monospace', |
||||
maxWidth: 'unset', |
||||
}, |
||||
'& > ul > li': { |
||||
padding: '2px 1em 2px 3px', |
||||
}, |
||||
'& li:hover': { |
||||
backgroundColor: '#ddd', |
||||
}, |
||||
'& > ul > li[aria-selected]': { |
||||
backgroundColor: '#d6ebff', |
||||
color: 'unset', |
||||
}, |
||||
minWidth: '30%', |
||||
}, |
||||
|
||||
'.cm-completionDetail': { |
||||
float: 'right', |
||||
color: '#999', |
||||
}, |
||||
|
||||
'.cm-tooltip.cm-completionInfo': { |
||||
marginTop: '-11px', |
||||
padding: '10px', |
||||
fontFamily: "'Open Sans', 'Lucida Sans Unicode', 'Lucida Grande', sans-serif;", |
||||
border: 'none', |
||||
backgroundColor: '#d6ebff', |
||||
minWidth: '250px', |
||||
maxWidth: 'min-content', |
||||
}, |
||||
|
||||
'.cm-completionInfo.cm-completionInfo-right': { |
||||
'&:before': { |
||||
content: "' '", |
||||
height: '0', |
||||
position: 'absolute', |
||||
width: '0', |
||||
left: '-20px', |
||||
border: '10px solid transparent', |
||||
borderRightColor: '#d6ebff', |
||||
}, |
||||
marginLeft: '12px', |
||||
}, |
||||
'.cm-completionInfo.cm-completionInfo-left': { |
||||
'&:before': { |
||||
content: "' '", |
||||
height: '0', |
||||
position: 'absolute', |
||||
width: '0', |
||||
right: '-20px', |
||||
border: '10px solid transparent', |
||||
borderLeftColor: '#d6ebff', |
||||
}, |
||||
marginRight: '12px', |
||||
}, |
||||
|
||||
'.cm-completionMatchedText': { |
||||
textDecoration: 'none', |
||||
fontWeight: 'bold', |
||||
color: '#0066bf', |
||||
}, |
||||
|
||||
'.cm-line': { |
||||
'&::selection': { |
||||
backgroundColor: '#add6ff', |
||||
}, |
||||
'& > span::selection': { |
||||
backgroundColor: '#add6ff', |
||||
}, |
||||
}, |
||||
|
||||
'.cm-selectionMatch': { |
||||
backgroundColor: '#e6f3ff', |
||||
}, |
||||
|
||||
'.cm-diagnostic': { |
||||
'&.cm-diagnostic-error': { |
||||
borderLeft: '3px solid #e65013', |
||||
}, |
||||
}, |
||||
|
||||
'.cm-completionIcon': { |
||||
boxSizing: 'content-box', |
||||
fontSize: '16px', |
||||
lineHeight: '1', |
||||
marginRight: '10px', |
||||
verticalAlign: 'top', |
||||
'&:after': { content: "'\\ea88'" }, |
||||
fontFamily: 'codicon', |
||||
paddingRight: '0', |
||||
opacity: '1', |
||||
color: '#007acc', |
||||
}, |
||||
|
||||
'.cm-completionIcon-function, .cm-completionIcon-method': { |
||||
'&:after': { content: "'\\ea8c'" }, |
||||
color: '#652d90', |
||||
}, |
||||
'.cm-completionIcon-class': { |
||||
'&:after': { content: "'○'" }, |
||||
}, |
||||
'.cm-completionIcon-interface': { |
||||
'&:after': { content: "'◌'" }, |
||||
}, |
||||
'.cm-completionIcon-variable': { |
||||
'&:after': { content: "'𝑥'" }, |
||||
}, |
||||
'.cm-completionIcon-constant': { |
||||
'&:after': { content: "'\\eb5f'" }, |
||||
color: '#007acc', |
||||
}, |
||||
'.cm-completionIcon-type': { |
||||
'&:after': { content: "'𝑡'" }, |
||||
}, |
||||
'.cm-completionIcon-enum': { |
||||
'&:after': { content: "'∪'" }, |
||||
}, |
||||
'.cm-completionIcon-property': { |
||||
'&:after': { content: "'□'" }, |
||||
}, |
||||
'.cm-completionIcon-keyword': { |
||||
'&:after': { content: "'\\eb62'" }, |
||||
color: '#616161', |
||||
}, |
||||
'.cm-completionIcon-namespace': { |
||||
'&:after': { content: "'▢'" }, |
||||
}, |
||||
'.cm-completionIcon-text': { |
||||
'&:after': { content: "'\\ea95'" }, |
||||
color: '#ee9d28', |
||||
}, |
||||
}); |
||||
|
||||
export const promqlHighlighter = HighlightStyle.define([ |
||||
{ tag: tags.name, color: '#000' }, |
||||
{ tag: tags.number, color: '#09885a' }, |
||||
{ tag: tags.string, color: '#a31515' }, |
||||
{ tag: tags.keyword, color: '#008080' }, |
||||
{ tag: tags.function(tags.variableName), color: '#008080' }, |
||||
{ tag: tags.labelName, color: '#800000' }, |
||||
{ tag: tags.operator }, |
||||
{ tag: tags.modifier, color: '#008080' }, |
||||
{ tag: tags.paren }, |
||||
{ tag: tags.squareBracket }, |
||||
{ tag: tags.brace }, |
||||
{ tag: tags.invalid, color: 'red' }, |
||||
{ tag: tags.comment, color: '#888', fontStyle: 'italic' }, |
||||
]); |
||||
@ -1,9 +1,23 @@ |
||||
import { configure } from 'enzyme'; |
||||
import Adapter from 'enzyme-adapter-react-16'; |
||||
import { GlobalWithFetchMock } from 'jest-fetch-mock'; |
||||
import 'mutationobserver-shim'; // Needed for CodeMirror.
|
||||
import './globals'; |
||||
|
||||
configure({ adapter: new Adapter() }); |
||||
const customGlobal: GlobalWithFetchMock = global as GlobalWithFetchMock; |
||||
customGlobal.fetch = require('jest-fetch-mock'); |
||||
customGlobal.fetchMock = customGlobal.fetch; |
||||
|
||||
// CodeMirror in the expression input requires this DOM API. When we upgrade react-scripts
|
||||
// and the associated Jest deps, hopefully this won't be needed anymore.
|
||||
document.getSelection = function() { |
||||
return { |
||||
addRange: function() { |
||||
return; |
||||
}, |
||||
removeAllRanges: function() { |
||||
return; |
||||
}, |
||||
}; |
||||
}; |
||||
|
||||
Loading…
Reference in new issue