From faacb619c0a5a0418ffdf788c40ef083dfe17039 Mon Sep 17 00:00:00 2001 From: Julius Volz Date: Tue, 23 Mar 2021 23:55:52 +0100 Subject: [PATCH] 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 * Group new editor options and float them left Signed-off-by: Julius Volz * Improve history autocompletion handling Signed-off-by: Julius Volz * Only show info tooltips for unabbreviated completion items Signed-off-by: Julius Volz * Rename "new editor" to "experimental editor" Signed-off-by: Julius Volz * Add path prefix support Signed-off-by: Julius Volz * Revert accidental check-in of go.sum changes Signed-off-by: Julius Volz * Remove spurious console.log Signed-off-by: Julius Volz * Fix completion item type icon styling Signed-off-by: Julius Volz --- NOTICE | 5 + web/ui/react-app/package.json | 19 +- web/ui/react-app/src/App.css | 15 +- web/ui/react-app/src/fonts/codicon.ttf | Bin 0 -> 61024 bytes web/ui/react-app/src/index.tsx | 1 + .../pages/graph/CMExpressionInput.test.tsx | 72 ++++ .../src/pages/graph/CMExpressionInput.tsx | 240 +++++++++++ web/ui/react-app/src/pages/graph/CMTheme.tsx | 183 +++++++++ web/ui/react-app/src/pages/graph/Panel.tsx | 36 +- .../src/pages/graph/PanelList.test.tsx | 12 +- .../react-app/src/pages/graph/PanelList.tsx | 99 +++-- web/ui/react-app/src/setupTests.ts | 14 + web/ui/react-app/yarn.lock | 380 ++++++++++++++++-- 13 files changed, 1005 insertions(+), 71 deletions(-) create mode 100644 web/ui/react-app/src/fonts/codicon.ttf create mode 100644 web/ui/react-app/src/pages/graph/CMExpressionInput.test.tsx create mode 100644 web/ui/react-app/src/pages/graph/CMExpressionInput.tsx create mode 100644 web/ui/react-app/src/pages/graph/CMTheme.tsx diff --git a/NOTICE b/NOTICE index 7c0e4c1020..33f226d9f8 100644 --- a/NOTICE +++ b/NOTICE @@ -91,6 +91,11 @@ https://github.com/dgryski/go-tsz Copyright (c) 2015,2016 Damian Gryski See https://github.com/dgryski/go-tsz/blob/master/LICENSE for license details. +The Codicon icon font from Microsoft +https://github.com/microsoft/vscode-codicons +Copyright (c) Microsoft Corporation and other contributors +See https://github.com/microsoft/vscode-codicons/blob/main/LICENSE for license details. + We also use code from a large number of npm packages. For details, see: - https://github.com/prometheus/prometheus/blob/main/web/ui/react-app/package.json - https://github.com/prometheus/prometheus/blob/main/web/ui/react-app/package-lock.json diff --git a/web/ui/react-app/package.json b/web/ui/react-app/package.json index 62a188d13c..713851ba83 100644 --- a/web/ui/react-app/package.json +++ b/web/ui/react-app/package.json @@ -3,11 +3,22 @@ "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.14", "@fortawesome/free-solid-svg-icons": "^5.7.1", "@fortawesome/react-fontawesome": "^0.1.4", "@reach/router": "^1.2.1", - "@testing-library/react-hooks": "^3.1.1", "@types/jest": "^26.0.10", "@types/jquery": "^3.5.1", "@types/node": "^12.11.1", @@ -18,6 +29,7 @@ "@types/react-resize-detector": "^5.0.0", "@types/sanitize-html": "^1.20.2", "bootstrap": "^4.2.1", + "codemirror-promql": "^0.13.0", "css.escape": "^1.5.1", "downshift": "^3.4.8", "enzyme-to-json": "^3.4.3", @@ -63,6 +75,7 @@ "not op_mini all" ], "devDependencies": { + "@testing-library/react-hooks": "^3.1.1", "@types/enzyme": "^3.10.3", "@types/enzyme-adapter-react-16": "^1.0.5", "@types/flot": "0.0.31", @@ -83,6 +96,7 @@ "eslint-plugin-react": "7.x", "eslint-plugin-react-hooks": "2.x", "jest-fetch-mock": "^3.0.3", + "mutationobserver-shim": "^0.3.7", "prettier": "^1.18.2", "sinon": "^9.0.3" }, @@ -90,6 +104,9 @@ "jest": { "snapshotSerializers": [ "enzyme-to-json/serializer" + ], + "transformIgnorePatterns": [ + "/node_modules/(?!codemirror-promql).+(js|jsx)$" ] } } diff --git a/web/ui/react-app/src/App.css b/web/ui/react-app/src/App.css index d34f68a254..b1f54660a5 100644 --- a/web/ui/react-app/src/App.css +++ b/web/ui/react-app/src/App.css @@ -36,10 +36,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 { diff --git a/web/ui/react-app/src/fonts/codicon.ttf b/web/ui/react-app/src/fonts/codicon.ttf new file mode 100644 index 0000000000000000000000000000000000000000..82acc8995b8d7ee0338bc65547844f5f99b953ce GIT binary patch literal 61024 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 z zIxk(X6Ar~^*VN?u-LJ$6c>Sqd=I%-MK=OKrI1@<(sUcwZb5m&kRQ`s_j*%-b9}Xs} zuDJW2i`NPdt>aI&MqEAhxx0=;6Xj!9Trrqpzs%or`GK=ae47@9R_zId`)?fEPoBj<^i#f45p>n(p$VFi; zO!x)avJd18tQ;U^4g!XO=1l)TXMNSuljqjvguhDc1~x{V7KMLdIcutu{v{W^W?+h_ z_zU{q0LFNV^z)*J&>WO_JwX!8{}6Rb>BbSH%Ufm=Y@ce;UQX^Au{^0LXJqj#8|S-n5CfB8P~gjxE2#SU}vIr^dw^FXMh zxMELnh4l62)~3$eH{P~E`mZ!wkdiKyUU_DQbx0rnX}(?xHgj0>3KE*jF!+4&_Gflg zDSGNu^qccy`Sfe#4!=M4X85i6w{XFH8S+?B%Es!!6-5YOcS2Mr-9XLqK* zVy-RC^FN|3@!)5edVu4K#?eF&QE-Sn zfcmMTPQiy;v_LMkFi`_UB+2vi0xTb7TfA5B9m&6eIK*Fo zIV#~CgTuy>Qs1CtfV8u3`&vQv&^%oNmp6gTrZsV*4hn*=E3^qZ7mxd zP}VCokyvG!+bf7#RiLijkDP=aM9jqFZf7*Ov;=L^sS7V|DDim>fdIILK_)Hh9kPwi z&M}P?`vwDJ#0jVq5spQWE*&9Yg#QQr0|-kZ--0^8;eu7 z-In@@ZhmI28F+Qa9HZQSk%AViXiYWt3;PA|oMm-hZ&}1%N6kWJ8YFRP7 z=p)8!3d)jgL9*}63Z0c$l0Jza4Pp&$TRCuBT~2H8Z$RQ9dkSB2{S9l{jfuKA`0K;5 zx&*r*=!@gW7n}l{y}qVI`1n22@iu5JwTN}<#(0UL8#&*bB5(_#6#+*CiAyrK92hGYIYBaj zkqq#8m`Dk21uqM5E=?F(MM}2mASOhbQa7d)k;X_pWO;qKOhxEW_Vf7AErH=7|7}C@ zfjffx_66_IU8z2IxAsc+IeT1}>(A}hUS8C7&Qhg{wFm}<+2yx-!*0F7=Cr6(NrTmC zHGl$HuhBYf4awxjVgIeev7y`j`}PHH8;T9z>Q7zaI=9PpMBCM+J>u#**L8*V+;iCh zjYB7hz9OC70;r)4c22+7QzA{U(+wCqg>2z8n$h@Q%lZ8H3D39j z&Q~?Yev5=7vA^$C27XI{YzSqhV*GZq1^MFM#u@xzRJ zCIts&S86&cdMTtKDIgF|AAiC}mgSam%39FUHf&2Iwq>>^ zKy$Cz=|!mS-4w0sB_!wHKJWwibFRuQg*`g4tq-Se?cdtKe!im^Cmv6E5w}}tSM$%$ zBW*rbj{p6$|J~2~zj{Wwem~^7#qH?-nC*Wg2D(tD$cYOrjnI0~urRZQa+ZQJ?T$D< z;kn9?+1=tX8Z~9{l4zzbFu&IJ947mpP=vPN-yWGiuk3>hVCj_QHtF-+Zz-dFV7`Qj z2et+)p>^a?A+~)Q)GllrN5{WMZug^ps4 X1|Ou=Q(*tda06I>Ma-t5~M?57D~u< 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 { + 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 new file mode 100644 index 0000000000..ab9de6d36e --- /dev/null +++ b/web/ui/react-app/src/pages/graph/CMExpressionInput.tsx @@ -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 { + 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(); + + // (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 ( + <> + + + + {loading ? : } + + +
+ + + + + + + + + + + ); +}; + +export default CMExpressionInput; diff --git a/web/ui/react-app/src/pages/graph/CMTheme.tsx b/web/ui/react-app/src/pages/graph/CMTheme.tsx new file mode 100644 index 0000000000..80cd394215 --- /dev/null +++ b/web/ui/react-app/src/pages/graph/CMTheme.tsx @@ -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' }, +]); diff --git a/web/ui/react-app/src/pages/graph/Panel.tsx b/web/ui/react-app/src/pages/graph/Panel.tsx index 7920072631..d498d2109c 100644 --- a/web/ui/react-app/src/pages/graph/Panel.tsx +++ b/web/ui/react-app/src/pages/graph/Panel.tsx @@ -5,6 +5,7 @@ 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'; @@ -22,7 +23,10 @@ interface PanelProps { removePanel: () => void; onExecuteQuery: (query: string) => void; pathPrefix: string; + useExperimentalEditor: boolean; enableAutocomplete: boolean; + enableHighlighting: boolean; + enableLinter: boolean; } interface PanelState { @@ -232,15 +236,29 @@ 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 833921ef02..023288c802 100755 --- a/web/ui/react-app/src/pages/graph/PanelList.test.tsx +++ b/web/ui/react-app/src/pages/graph/PanelList.test.tsx @@ -6,15 +6,19 @@ import { Button } from 'reactstrap'; import Panel from './Panel'; describe('PanelList', () => { - it('renders query history and local time checkboxes', () => { + it('renders configuration checkboxes', () => { [ - { id: 'query-history-checkbox', label: 'Enable query history' }, - { id: 'use-local-time-checkbox', label: 'Use local time' }, + { 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: false }, + { id: 'highlighting-checkbox', label: 'Enable highlighting', default: true }, + { id: 'linter-checkbox', label: 'Enable linter', default: true }, ].forEach((cb, idx) => { const panelList = shallow(); const checkbox = panelList.find(Checkbox).at(idx); expect(checkbox.prop('id')).toEqual(cb.id); - expect(checkbox.prop('defaultChecked')).toBe(false); + expect(checkbox.prop('defaultChecked')).toBe(cb.default); expect(checkbox.children().text()).toBe(cb.label); }); }); diff --git a/web/ui/react-app/src/pages/graph/PanelList.tsx b/web/ui/react-app/src/pages/graph/PanelList.tsx index 4e07587908..0181b2c625 100644 --- a/web/ui/react-app/src/pages/graph/PanelList.tsx +++ b/web/ui/react-app/src/pages/graph/PanelList.tsx @@ -17,19 +17,25 @@ export const updateURL = (nextPanels: PanelMeta[]) => { window.history.pushState({}, '', query); }; -interface PanelListProps extends RouteComponentProps { +interface PanelListContentProps extends RouteComponentProps { panels: PanelMeta[]; metrics: string[]; useLocalTime: boolean; + useExperimentalEditor: boolean; queryHistoryEnabled: boolean; enableAutocomplete: boolean; + enableHighlighting: boolean; + enableLinter: boolean; } -export const PanelListContent: FC = ({ +export const PanelListContent: FC = ({ metrics = [], useLocalTime, + useExperimentalEditor, queryHistoryEnabled, enableAutocomplete, + enableHighlighting, + enableLinter, ...rest }) => { const [panels, setPanels] = useState(rest.panels); @@ -99,10 +105,13 @@ export const PanelListContent: FC = ({ ) ) } + useExperimentalEditor={useExperimentalEditor} useLocalTime={useLocalTime} metricNames={metrics} pastQueries={queryHistoryEnabled ? historyItems : []} enableAutocomplete={enableAutocomplete} + enableHighlighting={enableHighlighting} + enableLinter={enableLinter} /> ))}