diff --git a/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx b/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx index c6f8dad7157..ada037d8b66 100644 --- a/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx +++ b/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx @@ -111,7 +111,7 @@ class UnthemedCodeEditor extends React.PureComponent { const value = this.props.value ?? ''; const longText = value.length > 100; - const styles = getStyles(theme); + const containerStyles = this.props.containerStyles ?? getStyles(theme).container; const options: MonacoOptions = { wordWrap: 'off', @@ -143,7 +143,7 @@ class UnthemedCodeEditor extends React.PureComponent { } return ( -
+
void; + onRunQuery: () => void; + label: string; + width: number; +} + +export function DynamicLabelsField({ label, width, onChange, onRunQuery }: Props) { + const theme = useTheme2(); + const styles = getInputStyles({ theme, width }); + const containerRef = useRef(null); + const onEditorMount = useCallback( + (editor: monacoType.editor.IStandaloneCodeEditor, monaco: Monaco) => { + editor.onDidFocusEditorText(() => editor.trigger(TRIGGER_SUGGEST.id, TRIGGER_SUGGEST.id, {})); + editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => { + const text = editor.getValue(); + onChange(text); + onRunQuery(); + }); + + const containerDiv = containerRef.current; + containerDiv !== null && editor.layout({ width: containerDiv.clientWidth, height: containerDiv.clientHeight }); + }, + [onChange, onRunQuery] + ); + + return ( +
+ { + if (value !== label) { + onChange(value); + onRunQuery(); + } + }} + onBeforeEditorMount={(monaco: Monaco) => + registerLanguage(monaco, language, dynamicLabelsCompletionItemProvider) + } + onEditorDidMount={onEditorMount} + /> +
+ ); +} diff --git a/public/app/plugins/datasource/cloudwatch/components/MathExpressionQueryField.tsx b/public/app/plugins/datasource/cloudwatch/components/MathExpressionQueryField.tsx index b9e89f2daa1..4a8964fbb44 100644 --- a/public/app/plugins/datasource/cloudwatch/components/MathExpressionQueryField.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/MathExpressionQueryField.tsx @@ -37,7 +37,7 @@ export function MathExpressionQueryField({ const updateElementHeight = () => { const containerDiv = containerRef.current; if (containerDiv !== null && editor.getContentHeight() < 200) { - const pixelHeight = editor.getContentHeight(); + const pixelHeight = Math.max(32, editor.getContentHeight()); containerDiv.style.height = `${pixelHeight}px`; containerDiv.style.width = '100%'; const pixelWidth = containerDiv.clientWidth; @@ -68,6 +68,9 @@ export function MathExpressionQueryField({ }, suggestFontSize: 12, wordWrap: 'on', + padding: { + top: 6, + }, }} language={language.id} value={query} diff --git a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx index 92349bb29e6..f99d0289837 100644 --- a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx @@ -4,6 +4,7 @@ import selectEvent from 'react-select-event'; import { DataSourceInstanceSettings } from '@grafana/data'; import { config } from '@grafana/runtime'; +import * as ui from '@grafana/ui'; import { TemplateSrv } from 'app/features/templating/template_srv'; import { CustomVariableModel, initialVariableModelState } from '../../../../../features/variables/types'; @@ -12,6 +13,13 @@ import { CloudWatchJsonData, MetricEditorMode, MetricQueryType } from '../../typ import { MetricsQueryEditor, Props } from './MetricsQueryEditor'; +jest.mock('@grafana/ui', () => ({ + ...jest.requireActual('@grafana/ui'), + CodeEditor: function CodeEditor({ value }: { value: string }) { + return
{value}
; + }, +})); + const setup = () => { const instanceSettings = { jsonData: { defaultRegion: 'us-east-1' }, @@ -173,9 +181,7 @@ describe('QueryEditor', () => { expect(screen.getByText('Label')).toBeInTheDocument(); expect(screen.queryByText('Alias')).toBeNull(); - expect(screen.getByLabelText('Label - optional')).toHaveValue( - "Period: ${PROP('Period')} InstanceId: ${PROP('Dim.InstanceId')}" - ); + expect(screen.getByText("Period: ${PROP('Period')} InstanceId: ${PROP('Dim.InstanceId')}")); config.featureToggles.cloudWatchDynamicLabels = originalValue; }); diff --git a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryEditor.tsx index 6f61af7724f..7b8b1e39149 100644 --- a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryEditor.tsx @@ -16,6 +16,7 @@ import { MetricQueryType, MetricStat, } from '../../types'; +import { DynamicLabelsField } from '../DynamicLabelsField'; import QueryHeader from '../QueryHeader'; import { Alias } from './Alias'; @@ -138,14 +139,12 @@ export const MetricsQueryEditor = (props: Props) => { optional tooltip="Change time series legend name using Dynamic labels. See documentation for details." > - ) => - onChange({ ...preparedQuery, label: event.target.value }) - } - /> + props.onChange({ ...query, label })} + > ) : ( { + const setup = new DynamicLabelsCompletionItemProvider(); + const monaco = MonacoMock as Monaco; + const provider = setup.getCompletionProvider(monaco, cloudWatchDynamicLabelsLanguageDefinition); + const { suggestions } = await provider.provideCompletionItems( + TextModel(value) as monacoTypes.editor.ITextModel, + position + ); + return suggestions; +}; + +describe('Dynamic labels: CompletionItemProvider', () => { + describe('getSuggestions', () => { + it('returns all dynamic labels in case current token is a whitespace', async () => { + const { query, position } = afterLabelValue; + const suggestions = await getSuggestions(query, position); + expect(suggestions.length).toEqual(DYNAMIC_LABEL_PATTERNS.length + 1); // + 1 for the dimension suggestions + }); + + it('should return suggestion for dimension label that has high prio', async () => { + const { query, position } = afterLabelValue; + const suggestions = await getSuggestions(query, position); + expect(suggestions.length).toBeTruthy(); + const highPrioSuggestsions = suggestions.filter((s) => s.sortText === CompletionItemPriority.High); + expect(highPrioSuggestsions.length).toBe(1); + expect(highPrioSuggestsions[0].label).toBe("${PROP('Dim.')}"); + }); + + it('doesnt return suggestions if cursor is inside a dynamic label', async () => { + const { query, position } = insideLabelValue; + const suggestions = await getSuggestions(query, position); + expect(suggestions.length).toBe(0); + }); + }); +}); diff --git a/public/app/plugins/datasource/cloudwatch/dynamic-labels/CompletionItemProvider.ts b/public/app/plugins/datasource/cloudwatch/dynamic-labels/CompletionItemProvider.ts new file mode 100644 index 00000000000..043d607e52c --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/dynamic-labels/CompletionItemProvider.ts @@ -0,0 +1,71 @@ +import type { Monaco, monacoTypes } from '@grafana/ui'; + +import { linkedTokenBuilder } from '../monarch/linkedTokenBuilder'; +import { LanguageDefinition } from '../monarch/register'; +import { Completeable, CompletionItemPriority, TokenTypes } from '../monarch/types'; + +import { DYNAMIC_LABEL_PATTERNS } from './language'; + +type CompletionItem = monacoTypes.languages.CompletionItem; + +export class DynamicLabelsCompletionItemProvider implements Completeable { + tokenTypes: TokenTypes; + + constructor() { + this.tokenTypes = { + Parenthesis: 'delimiter.parenthesis.cloudwatch-dynamicLabels', + Whitespace: 'white.cloudwatch-dynamicLabels', + Keyword: 'keyword.cloudwatch-dynamicLabels', + Delimiter: 'delimiter.cloudwatch-dynamicLabels', + Operator: 'operator.cloudwatch-dynamicLabels', + Identifier: 'identifier.cloudwatch-dynamicLabels', + Type: 'type.cloudwatch-dynamicLabels', + Function: 'predefined.cloudwatch-dynamicLabels', + Number: 'number.cloudwatch-dynamicLabels', + String: 'string.cloudwatch-dynamicLabels', + Variable: 'variable.cloudwatch-dynamicLabels', + }; + } + + // called by registerLanguage and passed to monaco with registerCompletionItemProvider + // returns an object that implements https://microsoft.github.io/monaco-editor/api/interfaces/monaco.languages.CompletionItemProvider.html + getCompletionProvider(monaco: Monaco, languageDefinition: LanguageDefinition) { + return { + triggerCharacters: [' ', '$', ',', '(', "'"], // one of these characters indicates that it is time to look for a suggestion + provideCompletionItems: async (model: monacoTypes.editor.ITextModel, position: monacoTypes.IPosition) => { + const currentToken = linkedTokenBuilder(monaco, languageDefinition, model, position, this.tokenTypes); + const invalidRangeToken = currentToken?.isWhiteSpace() || currentToken?.isParenthesis(); + const range = + invalidRangeToken || !currentToken?.range ? monaco.Range.fromPositions(position) : currentToken?.range; + const toCompletionItem = (value: string, rest: Partial = {}) => { + const item: CompletionItem = { + label: value, + insertText: value, + kind: monaco.languages.CompletionItemKind.Field, + range, + sortText: CompletionItemPriority.Medium, + ...rest, + }; + return item; + }; + let suggestions: CompletionItem[] = []; + const next = currentToken?.next; + if (!currentToken?.isFunction() && (!next || next.isWhiteSpace())) { + suggestions = DYNAMIC_LABEL_PATTERNS.map((val) => toCompletionItem(val)); + // always insert suggestion for dimension value and allow user to complete pattern by providing the dimension name + suggestions.push( + toCompletionItem("${PROP('Dim.')}", { + sortText: CompletionItemPriority.High, + insertText: `\${PROP('Dim.$0')} `, + insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + }) + ); + } + + return { + suggestions, + }; + }, + }; + } +} diff --git a/public/app/plugins/datasource/cloudwatch/dynamic-labels/definition.ts b/public/app/plugins/datasource/cloudwatch/dynamic-labels/definition.ts new file mode 100644 index 00000000000..3791b9c2b2e --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/dynamic-labels/definition.ts @@ -0,0 +1,10 @@ +import { LanguageDefinition } from '../monarch/register'; + +const cloudWatchDynamicLabelsLanguageDefinition: LanguageDefinition = { + id: 'cloudwatch-dynamicLabels', + extensions: [], + aliases: [], + mimetypes: [], + loader: () => import('./language'), +}; +export default cloudWatchDynamicLabelsLanguageDefinition; diff --git a/public/app/plugins/datasource/cloudwatch/dynamic-labels/language.ts b/public/app/plugins/datasource/cloudwatch/dynamic-labels/language.ts new file mode 100644 index 00000000000..690334c5b6a --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/dynamic-labels/language.ts @@ -0,0 +1,52 @@ +import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api'; + +// Dynamic labels: https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/graph-dynamic-labels.html +export const DYNAMIC_LABEL_PATTERNS = [ + '${DATAPOINT_COUNT}', + '${FIRST}', + '${FIRST_LAST_RANGE}', + '${FIRST_LAST_TIME_RANGE}', + '${FIRST_TIME}', + '${FIRST_TIME_RELATIVE}', + '${LABEL}', + '${LAST}', + '${LAST_TIME}', + '${LAST_TIME_RELATIVE}', + '${MAX}', + '${MAX_TIME}', + '${MAX_TIME_RELATIVE}', + '${MIN}', + '${MIN_MAX_RANGE}', + '${MIN_MAX_TIME_RANGE}', + '${MIN_TIME}', + '${MIN_TIME_RELATIVE}', + "${PROP('AccountId')}", + "${PROP('MetricName')}", + "${PROP('Namespace')}", + "${PROP('Period')}", + "${PROP('Region')}", + "${PROP('Stat')}", + '${SUM}', +]; + +export const language: monacoType.languages.IMonarchLanguage = { + id: 'dynamicLabels', + ignoreCase: false, + tokenizer: { + root: [ + { include: '@whitespace' }, + { include: '@builtInFunctions' }, + { include: '@string' }, + [/\$\{PROP\('Dim.[a-zA-Z0-9-_]?.*'\)\}+/, 'predefined'], //custom handling for dimension patterns + ], + builtInFunctions: [[DYNAMIC_LABEL_PATTERNS.map(escapeRegExp).join('|'), 'predefined']], + whitespace: [[/\s+/, 'white']], + string: [], + }, +}; + +export const conf: monacoType.languages.LanguageConfiguration = {}; + +function escapeRegExp(string: string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} diff --git a/public/app/plugins/datasource/cloudwatch/monarch/CompletionItemProvider.ts b/public/app/plugins/datasource/cloudwatch/monarch/CompletionItemProvider.ts index cf2d1dfed30..6f1b1bcbfa2 100644 --- a/public/app/plugins/datasource/cloudwatch/monarch/CompletionItemProvider.ts +++ b/public/app/plugins/datasource/cloudwatch/monarch/CompletionItemProvider.ts @@ -6,7 +6,7 @@ import { CloudWatchDatasource } from '../datasource'; import { LinkedToken } from './LinkedToken'; import { linkedTokenBuilder } from './linkedTokenBuilder'; import { LanguageDefinition } from './register'; -import { StatementPosition, SuggestionKind, TokenTypes } from './types'; +import { Completeable, StatementPosition, SuggestionKind, TokenTypes } from './types'; type CompletionItem = monacoTypes.languages.CompletionItem; @@ -17,7 +17,7 @@ CompletionItemProvider is an extendable class which needs to implement : - getSuggestionKinds - getSuggestions */ -export class CompletionItemProvider { +export class CompletionItemProvider implements Completeable { templateVariables: string[]; datasource: CloudWatchDatasource; templateSrv: TemplateSrv; diff --git a/public/app/plugins/datasource/cloudwatch/monarch/register.ts b/public/app/plugins/datasource/cloudwatch/monarch/register.ts index 3a3f7b4e2d6..4b552a4763a 100644 --- a/public/app/plugins/datasource/cloudwatch/monarch/register.ts +++ b/public/app/plugins/datasource/cloudwatch/monarch/register.ts @@ -2,7 +2,7 @@ import type * as monacoType from 'monaco-editor/esm/vs/editor/editor.api'; import { Monaco } from '@grafana/ui'; -import { CompletionItemProvider } from './CompletionItemProvider'; +import { Completeable } from './types'; export type LanguageDefinition = { id: string; @@ -18,7 +18,7 @@ export type LanguageDefinition = { export const registerLanguage = ( monaco: Monaco, language: LanguageDefinition, - completionItemProvider: CompletionItemProvider + completionItemProvider: Completeable ) => { const { id, loader } = language; diff --git a/public/app/plugins/datasource/cloudwatch/monarch/types.ts b/public/app/plugins/datasource/cloudwatch/monarch/types.ts index 5ca960e034e..c10424b78f6 100644 --- a/public/app/plugins/datasource/cloudwatch/monarch/types.ts +++ b/public/app/plugins/datasource/cloudwatch/monarch/types.ts @@ -1,5 +1,7 @@ import { monacoTypes } from '@grafana/ui'; +import { LanguageDefinition } from './register'; + export interface TokenTypes { Parenthesis: string; Whitespace: string; @@ -98,3 +100,10 @@ export interface Monaco { Range: Range; languages: Languages; } + +export interface Completeable { + getCompletionProvider( + monaco: Monaco, + languageDefinition: LanguageDefinition + ): monacoTypes.languages.CompletionItemProvider; +}