Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: Add new codemirror-promql-based expression editor #4030

Merged
merged 7 commits into from
Apr 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/react.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: [ '10', '12' ]
node: [ '14' ]
name: React UI test on Node ${{ matrix.node }}
steps:
- uses: actions/checkout@v2
Expand Down
281 changes: 153 additions & 128 deletions pkg/ui/bindata.go

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions pkg/ui/react-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@codemirror/autocomplete": "^0.18.3",
"@codemirror/closebrackets": "^0.18.0",
"@codemirror/commands": "^0.18.0",
"@codemirror/comment": "^0.18.0",
"@codemirror/highlight": "^0.18.3",
"@codemirror/history": "^0.18.0",
"@codemirror/language": "^0.18.0",
"@codemirror/lint": "^0.18.1",
"@codemirror/matchbrackets": "^0.18.0",
"@codemirror/search": "^0.18.2",
"@codemirror/state": "^0.18.2",
"@codemirror/view": "^0.18.3",
"@fortawesome/fontawesome-svg-core": "^1.2.34",
"@fortawesome/free-solid-svg-icons": "^5.15.2",
"@fortawesome/react-fontawesome": "^0.1.14",
Expand All @@ -19,6 +31,7 @@
"@types/react-select": "^4.0.13",
"@types/sanitize-html": "^1.27.1",
"bootstrap": "^4.6.0",
"codemirror-promql": "^0.14.1",
"css.escape": "^1.5.1",
"downshift": "^6.1.0",
"enzyme-to-json": "^3.6.1",
Expand Down Expand Up @@ -68,6 +81,7 @@
"not op_mini all"
],
"devDependencies": {
"@testing-library/react-hooks": "^3.1.1",
"@types/enzyme": "^3.10.8",
"@types/enzyme-adapter-react-16": "^1.0.6",
"@types/flot": "^0.0.31",
Expand All @@ -89,13 +103,17 @@
"eslint-plugin-react": "^7.22.0",
"eslint-plugin-react-hooks": "^4.2.0",
"jest-fetch-mock": "^2.1.2",
"mutationobserver-shim": "^0.3.7",
"prettier": "^2.2.1",
"sinon": "^9.2.4"
},
"proxy": "http://localhost:10902",
"jest": {
"snapshotSerializers": [
"enzyme-to-json/serializer"
],
"transformIgnorePatterns": [
"/node_modules/(?!codemirror-promql).+(js|jsx)$"
]
}
}
15 changes: 11 additions & 4 deletions pkg/ui/react-app/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,17 @@ input[type='checkbox']:checked + label {
margin-bottom: 10px;
}

.expression-input textarea {
/* font-family: Menlo,Monaco,Consolas,'Courier New',monospace; */
resize: none;
overflow: hidden;
.expression-input .cm-expression-input {
border: 1px solid #ced4da;
flex: 1 1 auto;
padding: 4px 0 0 8px;
font-size: 15px;
}

/* Font used for autocompletion item icons. */
@font-face {
font-family: 'codicon';
src: local('codicon'), url(./fonts/codicon.ttf) format('truetype');
}

button.execute-btn {
Expand Down
Binary file added pkg/ui/react-app/src/fonts/codicon.ttf
Binary file not shown.
1 change: 1 addition & 0 deletions pkg/ui/react-app/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import 'bootstrap/dist/css/bootstrap.min.css';
import './fonts/codicon.ttf';
import { isPresent } from './utils';

// Declared/defined in public/index.html, value replaced by Prometheus when serving bundle.
Expand Down
69 changes: 69 additions & 0 deletions pkg/ui/react-app/src/pages/graph/CMExpressionInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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);
});
});
215 changes: 215 additions & 0 deletions pkg/ui/react-app/src/pages/graph/CMExpressionInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import React, { FC, 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 } from '@fortawesome/free-solid-svg-icons';
import { CompleteStrategy, newCompleteStrategy } from 'codemirror-promql/complete';
import PathPrefixProps from '../../types/PathPrefixProps';

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<PathPrefixProps & CMExpressionInputProps> = ({
pathPrefix,
value,
onExpressionChange,
queryHistory,
metricNames,
executeQuery,
loading,
enableAutocomplete,
enableHighlighting,
enableLinter,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);

// (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 ? 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(),
EditorView.lineWrapping,
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]);

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="execute-btn" color="primary" onClick={executeQuery}>
Execute
</Button>
</InputGroupAddon>
</InputGroup>
</>
);
};

export default CMExpressionInput;
Loading