From 62633ba4a7888223460268e5d758e155bf2a4161 Mon Sep 17 00:00:00 2001 From: Andre Pereira Date: Thu, 12 Jan 2023 12:06:28 +0000 Subject: [PATCH] Tempo - Replace slate with monaco editor in search tags field (#61168) * Recreate the tempo search tags field with monaco editor instead of slate * Remove test file no longer needed --- .../tempo/QueryEditor/NativeSearch.tsx | 75 +----- .../tempo/QueryEditor/TagsField/TagsField.tsx | 187 +++++++++++++ .../QueryEditor/TagsField/autocomplete.ts | 255 ++++++++++++++++++ .../tempo/QueryEditor/TagsField/syntax.ts | 124 +++++++++ .../plugins/datasource/tempo/syntax.test.ts | 20 -- public/app/plugins/datasource/tempo/syntax.ts | 17 -- 6 files changed, 574 insertions(+), 104 deletions(-) create mode 100644 public/app/plugins/datasource/tempo/QueryEditor/TagsField/TagsField.tsx create mode 100644 public/app/plugins/datasource/tempo/QueryEditor/TagsField/autocomplete.ts create mode 100644 public/app/plugins/datasource/tempo/QueryEditor/TagsField/syntax.ts delete mode 100644 public/app/plugins/datasource/tempo/syntax.test.ts delete mode 100644 public/app/plugins/datasource/tempo/syntax.ts diff --git a/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.tsx b/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.tsx index 79703c2e7e0..eb46bc551e5 100644 --- a/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.tsx +++ b/public/app/plugins/datasource/tempo/QueryEditor/NativeSearch.tsx @@ -1,33 +1,19 @@ import { css } from '@emotion/css'; -import Prism from 'prismjs'; import React, { useCallback, useState, useEffect, useMemo } from 'react'; -import { Node } from 'slate'; import { GrafanaTheme2, isValidGoDuration, SelectableValue, toOption } from '@grafana/data'; import { FetchError, getTemplateSrv, isFetchError, TemplateSrv } from '@grafana/runtime'; -import { - InlineFieldRow, - InlineField, - Input, - QueryField, - SlatePrism, - BracesPlugin, - TypeaheadInput, - TypeaheadOutput, - Alert, - useStyles2, - fuzzyMatch, - Select, -} from '@grafana/ui'; +import { InlineFieldRow, InlineField, Input, Alert, useStyles2, fuzzyMatch, Select } from '@grafana/ui'; import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; import { dispatch } from 'app/store/store'; import { DEFAULT_LIMIT, TempoDatasource } from '../datasource'; import TempoLanguageProvider from '../language_provider'; -import { tokenizer } from '../syntax'; import { TempoQuery } from '../types'; +import { TagsField } from './TagsField/TagsField'; + interface Props { datasource: TempoDatasource; query: TempoQuery; @@ -36,22 +22,11 @@ interface Props { onRunQuery: () => void; } -const PRISM_LANGUAGE = 'tempo'; const durationPlaceholder = 'e.g. 1.2s, 100ms'; -const plugins = [ - BracesPlugin(), - SlatePrism({ - onlyIn: (node: Node) => node.object === 'block' && node.type === 'code_block', - getSyntax: () => PRISM_LANGUAGE, - }), -]; - -Prism.languages[PRISM_LANGUAGE] = tokenizer; const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props) => { const styles = useStyles2(getStyles); const languageProvider = useMemo(() => new TempoLanguageProvider(datasource), [datasource]); - const [hasSyntaxLoaded, setHasSyntaxLoaded] = useState(false); const [serviceOptions, setServiceOptions] = useState>>(); const [spanOptions, setSpanOptions] = useState>>(); const [error, setError] = useState(null); @@ -111,35 +86,6 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props fetchOptions(); }, [languageProvider, loadOptions, query.serviceName, query.spanName]); - useEffect(() => { - const fetchTags = async () => { - try { - await languageProvider.start(); - setHasSyntaxLoaded(true); - } catch (error) { - if (error instanceof Error) { - dispatch(notifyApp(createErrorNotification('Error', error))); - } - } - }; - fetchTags(); - }, [languageProvider]); - - const onTypeahead = useCallback( - async (typeahead: TypeaheadInput): Promise => { - return await languageProvider.provideCompletionItems(typeahead); - }, - [languageProvider] - ); - - const cleanText = useCallback((text: string) => { - const splittedText = text.split(/\s+(?=([^"]*"[^"]*")*[^"]*$)/g); - if (splittedText.length > 1) { - return splittedText[splittedText.length - 1]; - } - return text; - }, []); - const onKeyDown = (keyEvent: React.KeyboardEvent) => { if (keyEvent.key === 'Enter' && (keyEvent.shiftKey || keyEvent.ctrlKey)) { onRunQuery(); @@ -219,17 +165,12 @@ const NativeSearch = ({ datasource, query, onChange, onBlur, onRunQuery }: Props - diff --git a/public/app/plugins/datasource/tempo/QueryEditor/TagsField/TagsField.tsx b/public/app/plugins/datasource/tempo/QueryEditor/TagsField/TagsField.tsx new file mode 100644 index 00000000000..8737c4e811c --- /dev/null +++ b/public/app/plugins/datasource/tempo/QueryEditor/TagsField/TagsField.tsx @@ -0,0 +1,187 @@ +import { css } from '@emotion/css'; +import React, { useEffect, useRef } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { CodeEditor, Monaco, monacoTypes, useTheme2 } from '@grafana/ui'; + +import { createErrorNotification } from '../../../../../core/copy/appNotification'; +import { notifyApp } from '../../../../../core/reducers/appNotification'; +import { dispatch } from '../../../../../store/store'; +import { TempoDatasource } from '../../datasource'; + +import { CompletionProvider } from './autocomplete'; +import { languageDefinition } from './syntax'; + +interface Props { + placeholder: string; + value: string; + onChange: (val: string) => void; + onBlur?: () => void; + datasource: TempoDatasource; +} + +export function TagsField(props: Props) { + const { onChange, onBlur, placeholder } = props; + const setupAutocompleteFn = useAutocomplete(props.datasource); + const theme = useTheme2(); + const styles = getStyles(theme, placeholder); + + return ( + { + setupAutocompleteFn(editor, monaco); + setupPlaceholder(editor, monaco, styles); + setupAutoSize(editor); + }} + /> + ); +} + +function setupPlaceholder(editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco, styles: EditorStyles) { + const placeholderDecorators = [ + { + range: new monaco.Range(1, 1, 1, 1), + options: { + className: styles.placeholder, // The placeholder text is in styles.placeholder + isWholeLine: true, + }, + }, + ]; + + let decorators: string[] = []; + + const checkDecorators = (): void => { + const model = editor.getModel(); + + if (!model) { + return; + } + + const newDecorators = model.getValueLength() === 0 ? placeholderDecorators : []; + decorators = model.deltaDecorations(decorators, newDecorators); + }; + + checkDecorators(); + editor.onDidChangeModelContent(checkDecorators); +} + +function setupAutoSize(editor: monacoTypes.editor.IStandaloneCodeEditor) { + const container = editor.getDomNode(); + const updateHeight = () => { + if (container) { + const contentHeight = Math.min(1000, editor.getContentHeight()); + const width = parseInt(container.style.width, 10); + container.style.width = `${width}px`; + container.style.height = `${contentHeight}px`; + editor.layout({ width, height: contentHeight }); + } + }; + editor.onDidContentSizeChange(updateHeight); + updateHeight(); +} + +/** + * Hook that returns function that will set up monaco autocomplete for the label selector + * @param datasource + */ +function useAutocomplete(datasource: TempoDatasource) { + // We need the provider ref so we can pass it the label/values data later. This is because we run the call for the + // values here but there is additional setup needed for the provider later on. We could run the getSeries() in the + // returned function but that is run after the monaco is mounted so would delay the request a bit when it does not + // need to. + const providerRef = useRef( + new CompletionProvider({ languageProvider: datasource.languageProvider }) + ); + + useEffect(() => { + const fetchTags = async () => { + try { + await datasource.languageProvider.start(); + const tags = datasource.languageProvider.getTags(); + + if (tags) { + providerRef.current.setTags(tags); + } + } catch (error) { + if (error instanceof Error) { + dispatch(notifyApp(createErrorNotification('Error', error))); + } + } + }; + fetchTags(); + }, [datasource]); + + const autocompleteDisposeFun = useRef<(() => void) | null>(null); + useEffect(() => { + // when we unmount, we unregister the autocomplete-function, if it was registered + return () => { + autocompleteDisposeFun.current?.(); + }; + }, []); + + // This should be run in monaco onEditorDidMount + return (editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => { + providerRef.current.editor = editor; + providerRef.current.monaco = monaco; + + const { dispose } = monaco.languages.registerCompletionItemProvider(langId, providerRef.current); + autocompleteDisposeFun.current = dispose; + }; +} + +// we must only run the setup code once +let setupDone = false; +const langId = 'tagsfield'; + +function ensureTraceQL(monaco: Monaco) { + if (!setupDone) { + setupDone = true; + const { aliases, extensions, mimetypes, def } = languageDefinition; + monaco.languages.register({ id: langId, aliases, extensions, mimetypes }); + monaco.languages.setMonarchTokensProvider(langId, def.language); + monaco.languages.setLanguageConfiguration(langId, def.languageConfiguration); + } +} + +interface EditorStyles { + placeholder: string; + queryField: string; +} + +const getStyles = (theme: GrafanaTheme2, placeholder: string): EditorStyles => { + return { + queryField: css` + border-radius: ${theme.shape.borderRadius()}; + border: 1px solid ${theme.components.input.borderColor}; + flex: 1; + `, + placeholder: css` + ::after { + content: '${placeholder}'; + font-family: ${theme.typography.fontFamilyMonospace}; + opacity: 0.3; + } + `, + }; +}; diff --git a/public/app/plugins/datasource/tempo/QueryEditor/TagsField/autocomplete.ts b/public/app/plugins/datasource/tempo/QueryEditor/TagsField/autocomplete.ts new file mode 100644 index 00000000000..a8ac9d088e3 --- /dev/null +++ b/public/app/plugins/datasource/tempo/QueryEditor/TagsField/autocomplete.ts @@ -0,0 +1,255 @@ +import { SelectableValue } from '@grafana/data'; +import type { Monaco, monacoTypes } from '@grafana/ui'; + +import TempoLanguageProvider from '../../language_provider'; + +interface Props { + languageProvider: TempoLanguageProvider; +} + +/** + * Class that implements CompletionItemProvider interface and allows us to provide suggestion for the Monaco + * autocomplete system. + */ +export class CompletionProvider implements monacoTypes.languages.CompletionItemProvider { + languageProvider: TempoLanguageProvider; + + constructor(props: Props) { + this.languageProvider = props.languageProvider; + } + + triggerCharacters = ['=', ' ']; + + // We set these directly and ae required for the provider to function. + monaco: Monaco | undefined; + editor: monacoTypes.editor.IStandaloneCodeEditor | undefined; + + private tags: { [tag: string]: Set } = {}; + private cachedValues: { [key: string]: Array> } = {}; + + provideCompletionItems( + model: monacoTypes.editor.ITextModel, + position: monacoTypes.Position + ): monacoTypes.languages.ProviderResult { + // Should not happen, this should not be called before it is initialized + if (!(this.monaco && this.editor)) { + throw new Error('provideCompletionItems called before CompletionProvider was initialized'); + } + + // if the model-id does not match, then this call is from a different editor-instance, + // not "our instance", so return nothing + if (this.editor.getModel()?.id !== model.id) { + return { suggestions: [] }; + } + + const { range, offset } = getRangeAndOffset(this.monaco, model, position); + const situation = this.getSituation(model.getValue(), offset); + const completionItems = this.getCompletions(situation); + + return completionItems.then((items) => { + // monaco by-default alphabetically orders the items. + // to stop it, we use a number-as-string sortkey, + // so that monaco keeps the order we use + const maxIndexDigits = items.length.toString().length; + const suggestions: monacoTypes.languages.CompletionItem[] = items.map((item, index) => { + const suggestion: monacoTypes.languages.CompletionItem = { + kind: getMonacoCompletionItemKind(item.type, this.monaco!), + label: item.label, + insertText: item.insertText, + sortText: index.toString().padStart(maxIndexDigits, '0'), // to force the order we have + range, + }; + return suggestion; + }); + return { suggestions }; + }); + } + + /** + * We expect the tags list data directly from the request and assign it an empty set here. + */ + setTags(tags: string[]) { + tags.forEach((t) => (this.tags[t] = new Set())); + } + + private async getTagValues(tagName: string): Promise>> { + let tagValues: Array>; + + if (this.cachedValues.hasOwnProperty(tagName)) { + tagValues = this.cachedValues[tagName]; + } else { + tagValues = await this.languageProvider.getOptions(tagName); + this.cachedValues[tagName] = tagValues; + } + return tagValues; + } + + /** + * Get suggestion based on the situation we are in like whether we should suggest tag names or values. + * @param situation + * @private + */ + private async getCompletions(situation: Situation): Promise { + if (!Object.keys(this.tags).length) { + return []; + } + switch (situation.type) { + // Not really sure what would make sense to suggest in this case so just leave it + case 'UNKNOWN': { + return []; + } + case 'EMPTY': { + return this.getTagsCompletions(); + } + case 'IN_NAME': + return this.getTagsCompletions(); + case 'IN_VALUE': + const tagValues = await this.getTagValues(situation.tagName); + const items: Completion[] = []; + + const getInsertionText = (val: SelectableValue): string => `"${val.label}"`; + + tagValues.forEach((val) => { + if (val?.label) { + items.push({ + label: val.label, + insertText: getInsertionText(val), + type: 'TAG_VALUE', + }); + } + }); + return items; + default: + throw new Error(`Unexpected situation ${situation}`); + } + } + + private getTagsCompletions(): Completion[] { + return Object.keys(this.tags) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'accent' })) + .map((key) => ({ + label: key, + insertText: key, + type: 'TAG_NAME', + })); + } + + /** + * Figure out where is the cursor and what kind of suggestions are appropriate. + * @param text + * @param offset + */ + private getSituation(text: string, offset: number): Situation { + if (text === '' || offset === 0 || text[text.length - 1] === ' ') { + return { + type: 'EMPTY', + }; + } + + const textUntilCaret = text.substring(0, offset); + + const regex = /(?[^= ]+)(?=)?(?([^ "]+)|"([^"]*)")?/; + const matches = textUntilCaret.match(new RegExp(regex, 'g')); + + if (matches?.length) { + const last = matches[matches.length - 1]; + const lastMatched = last.match(regex); + if (lastMatched) { + const key = lastMatched.groups?.key; + const equals = lastMatched.groups?.equals; + + if (!key) { + return { + type: 'EMPTY', + }; + } + + if (!equals) { + return { + type: 'IN_NAME', + }; + } + + return { + type: 'IN_VALUE', + tagName: key, + }; + } + } + + return { + type: 'EMPTY', + }; + } +} + +/** + * Get item kind which is used for icon next to the suggestion. + * @param type + * @param monaco + */ +function getMonacoCompletionItemKind(type: CompletionType, monaco: Monaco): monacoTypes.languages.CompletionItemKind { + switch (type) { + case 'TAG_NAME': + return monaco.languages.CompletionItemKind.Enum; + case 'KEYWORD': + return monaco.languages.CompletionItemKind.Keyword; + case 'OPERATOR': + return monaco.languages.CompletionItemKind.Operator; + case 'TAG_VALUE': + return monaco.languages.CompletionItemKind.EnumMember; + case 'SCOPE': + return monaco.languages.CompletionItemKind.Class; + default: + throw new Error(`Unexpected CompletionType: ${type}`); + } +} + +export type CompletionType = 'TAG_NAME' | 'TAG_VALUE' | 'KEYWORD' | 'OPERATOR' | 'SCOPE'; +type Completion = { + type: CompletionType; + label: string; + insertText: string; +}; + +export type Tag = { + name: string; + value: string; +}; + +export type Situation = + | { + type: 'UNKNOWN'; + } + | { + type: 'EMPTY'; + } + | { + type: 'IN_NAME'; + } + | { + type: 'IN_VALUE'; + tagName: string; + }; + +function getRangeAndOffset(monaco: Monaco, model: monacoTypes.editor.ITextModel, position: monacoTypes.Position) { + const word = model.getWordAtPosition(position); + const range = + word != null + ? monaco.Range.lift({ + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }) + : monaco.Range.fromPositions(position); + + // documentation says `position` will be "adjusted" in `getOffsetAt` so we clone it here just for sure. + const positionClone = { + column: position.column, + lineNumber: position.lineNumber, + }; + + const offset = model.getOffsetAt(positionClone); + return { offset, range }; +} diff --git a/public/app/plugins/datasource/tempo/QueryEditor/TagsField/syntax.ts b/public/app/plugins/datasource/tempo/QueryEditor/TagsField/syntax.ts new file mode 100644 index 00000000000..6dd942b2f11 --- /dev/null +++ b/public/app/plugins/datasource/tempo/QueryEditor/TagsField/syntax.ts @@ -0,0 +1,124 @@ +import type { languages } from 'monaco-editor'; + +export const languageConfiguration: languages.LanguageConfiguration = { + // the default separators except `@$` + wordPattern: /(-?\d*\.\d\w*)|([^`~!#%^&*()\-=+\[{\]}\\|;:'",.<>\/?\s]+)/g, + brackets: [ + ['{', '}'], + ['(', ')'], + ], + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + ], + surroundingPairs: [ + { open: '{', close: '}' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + ], + folding: {}, +}; + +const operators = ['=']; + +export const language: languages.IMonarchLanguage = { + ignoreCase: false, + defaultToken: '', + tokenPostfix: '.tagsfield', + + operators, + + // we include these common regular expressions + symbols: /[=>|<|>=|<=|=~|!~))/, 'tag'], + + // all keywords have the same color + [ + /[a-zA-Z_.]\w*/, + { + cases: { + '@default': 'identifier', + }, + }, + ], + + // strings + [/"([^"\\]|\\.)*$/, 'string.invalid'], // non-teminated string + [/'([^'\\]|\\.)*$/, 'string.invalid'], // non-teminated string + [/"/, 'string', '@string_double'], + [/'/, 'string', '@string_single'], + + // whitespace + { include: '@whitespace' }, + + // delimiters and operators + [/[{}()\[\]]/, '@brackets'], + [/[<>](?!@symbols)/, '@brackets'], + [ + /@symbols/, + { + cases: { + '@operators': 'delimiter', + '@default': '', + }, + }, + ], + + // numbers + [/\d+/, 'number'], + [/\d*\d+[eE]([\-+]?\d+)?(@floatsuffix)/, 'number.float'], + [/\d*\.\d+([eE][\-+]?\d+)?(@floatsuffix)/, 'number.float'], + [/0[xX][0-9a-fA-F']*[0-9a-fA-F](@integersuffix)/, 'number.hex'], + [/0[0-7']*[0-7](@integersuffix)/, 'number.octal'], + [/0[bB][0-1']*[0-1](@integersuffix)/, 'number.binary'], + [/\d[\d']*\d(@integersuffix)/, 'number'], + [/\d(@integersuffix)/, 'number'], + ], + + string_double: [ + [/[^\\"]+/, 'string'], + [/@escapes/, 'string.escape'], + [/\\./, 'string.escape.invalid'], + [/"/, 'string', '@pop'], + ], + + string_single: [ + [/[^\\']+/, 'string'], + [/@escapes/, 'string.escape'], + [/\\./, 'string.escape.invalid'], + [/'/, 'string', '@pop'], + ], + + clauses: [ + [/[^(,)]/, 'tag'], + [/\)/, 'identifier', '@pop'], + ], + + whitespace: [[/[ \t\r\n]+/, 'white']], + }, +}; + +export const languageDefinition = { + id: 'tagsfield', + extensions: ['.tagsfield'], + aliases: ['tagsfield'], + mimetypes: [], + def: { + language, + languageConfiguration, + }, +}; diff --git a/public/app/plugins/datasource/tempo/syntax.test.ts b/public/app/plugins/datasource/tempo/syntax.test.ts deleted file mode 100644 index 1b0a732fbdf..00000000000 --- a/public/app/plugins/datasource/tempo/syntax.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import Prism from 'prismjs'; - -import { tokenizer } from './syntax'; - -describe('Loki syntax', () => { - it('should highlight Loki query correctly', () => { - expect(Prism.highlight('key=value', tokenizer, 'tempo')).toBe( - 'key=value' - ); - expect(Prism.highlight('root.ip=172.123.0.1', tokenizer, 'tempo')).toBe( - 'root.ip=172.123.0.1' - ); - expect(Prism.highlight('root.name="http get /config"', tokenizer, 'tempo')).toBe( - 'root.name="http get /config"' - ); - expect(Prism.highlight('key=value key2=value2', tokenizer, 'tempo')).toBe( - 'key=value key2=value2' - ); - }); -}); diff --git a/public/app/plugins/datasource/tempo/syntax.ts b/public/app/plugins/datasource/tempo/syntax.ts deleted file mode 100644 index 01dcb2166cb..00000000000 --- a/public/app/plugins/datasource/tempo/syntax.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Grammar } from 'prismjs'; - -export const tokenizer: Grammar = { - key: { - pattern: /[^\s]+(?==)/, - alias: 'attr-name', - }, - operator: /[=]/, - value: [ - { - pattern: /"(.+)"/, - }, - { - pattern: /[^\s]+/, - }, - ], -};