From 317bc9463460bfb4f79c8ab26eb3e21b0a72f433 Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Mon, 12 Jan 2026 16:49:19 +0100 Subject: [PATCH] implement codemirror instead slate-react --- packages/grafana-ui/package.json | 6 + .../DataLinks/DataLinkInput.test.tsx | 229 ++++++++++ .../components/DataLinks/DataLinkInput.tsx | 349 ++++++--------- .../DataLinks/codemirrorUtils.test.ts | 412 ++++++++++++++++++ .../components/DataLinks/codemirrorUtils.ts | 241 ++++++++++ yarn.lock | 111 ++++- 6 files changed, 1134 insertions(+), 214 deletions(-) create mode 100644 packages/grafana-ui/src/components/DataLinks/DataLinkInput.test.tsx create mode 100644 packages/grafana-ui/src/components/DataLinks/codemirrorUtils.test.ts create mode 100644 packages/grafana-ui/src/components/DataLinks/codemirrorUtils.ts diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index e7b908fc08c..d308a854ca4 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -63,6 +63,11 @@ "not IE 11" ], "dependencies": { + "@codemirror/autocomplete": "^6.12.0", + "@codemirror/commands": "^6.3.3", + "@codemirror/language": "^6.10.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.23.0", "@emotion/css": "11.13.5", "@emotion/react": "11.14.0", "@emotion/serialize": "1.3.3", @@ -73,6 +78,7 @@ "@grafana/i18n": "12.4.0-pre", "@grafana/schema": "12.4.0-pre", "@hello-pangea/dnd": "18.0.1", + "@lezer/highlight": "^1.2.0", "@monaco-editor/react": "4.7.0", "@popperjs/core": "2.11.8", "@rc-component/drawer": "1.3.0", diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.test.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.test.tsx new file mode 100644 index 00000000000..f68e301f7f8 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.test.tsx @@ -0,0 +1,229 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useEffect } from 'react'; +import * as React from 'react'; + +import { DataLinkBuiltInVars, VariableOrigin, VariableSuggestion } from '@grafana/data'; + +import { DataLinkInput } from './DataLinkInput'; + +const mockSuggestions: VariableSuggestion[] = [ + { + value: DataLinkBuiltInVars.seriesName, + label: '__series.name', + documentation: 'Series name', + origin: VariableOrigin.Series, + }, + { + value: DataLinkBuiltInVars.fieldName, + label: '__field.name', + documentation: 'Field name', + origin: VariableOrigin.Field, + }, + { + value: 'myVar', + label: 'myVar', + documentation: 'Custom variable', + origin: VariableOrigin.Template, + }, +]; + +describe('DataLinkInput', () => { + it('renders with initial value', async () => { + const onChange = jest.fn(); + render( + + ); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toBeInTheDocument(); + }); + }); + + it('renders with placeholder when value is empty', async () => { + const onChange = jest.fn(); + const placeholder = 'Enter URL here'; + + render(); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toHaveAttribute('aria-label', placeholder); + }); + }); + + it('calls onChange when value changes', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + const editor = screen.getByRole('textbox'); + await user.click(editor); + await user.keyboard('test'); + + await waitFor(() => { + expect(onChange).toHaveBeenCalled(); + }); + }); + + it('shows suggestions menu when $ is typed', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + const editor = screen.getByRole('textbox'); + await user.click(editor); + await user.keyboard('$'); + + await waitFor(() => { + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); + }); + + it('shows suggestions menu when = is typed', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + const editor = screen.getByRole('textbox'); + await user.click(editor); + await user.keyboard('='); + + await waitFor(() => { + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); + }); + + it('closes suggestions on Escape key', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + const editor = screen.getByRole('textbox'); + await user.click(editor); + await user.keyboard('$'); + + await waitFor(() => { + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + }); + + it('navigates suggestions with arrow keys', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + const editor = screen.getByRole('textbox'); + await user.click(editor); + await user.keyboard('$'); + + await waitFor(() => { + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); + + // Navigate with arrow keys + await user.keyboard('{ArrowDown}'); + await user.keyboard('{ArrowUp}'); + + // Menu should still be visible + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); + + it('inserts variable on Enter key', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + render(); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + const editor = screen.getByRole('textbox'); + await user.click(editor); + await user.keyboard('$'); + + await waitFor(() => { + expect(screen.getByRole('menu')).toBeInTheDocument(); + }); + + await user.keyboard('{Enter}'); + + await waitFor(() => { + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + // Should have called onChange with the inserted variable + expect(onChange).toHaveBeenCalled(); + }); + + it('updates when external value prop changes', async () => { + const onChange = jest.fn(); + + function TestWrapper({ initialValue }: { initialValue: string }) { + const [value, setValue] = React.useState(initialValue); + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + return ; + } + + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + rerender(); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toBeInTheDocument(); + }); + }); + + it('displays component with default placeholder', async () => { + const onChange = jest.fn(); + + render(); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toHaveAttribute('aria-label', 'http://your-grafana.com/d/000000010/annotations'); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx index e86bbd725e6..2ba85455a44 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx @@ -1,27 +1,29 @@ -import { css, cx } from '@emotion/css'; -import { autoUpdate, offset, useFloating } from '@floating-ui/react'; -import Prism, { Grammar, LanguageMap } from 'prismjs'; -import { memo, useEffect, useRef, useState } from 'react'; -import * as React from 'react'; -import { usePrevious } from 'react-use'; -import { Value } from 'slate'; -import Plain from 'slate-plain-serializer'; -import { Editor } from 'slate-react'; +import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete'; +import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; +import { bracketMatching, foldGutter, indentOnInput } from '@codemirror/language'; +import { Compartment, EditorState } from '@codemirror/state'; +import { + drawSelection, + dropCursor, + EditorView, + highlightActiveLine, + highlightSpecialChars, + keymap, + lineNumbers, + placeholder as placeholderExtension, + rectangularSelection, + tooltips, + ViewUpdate, +} from '@codemirror/view'; +import { css } from '@emotion/css'; +import { memo, useEffect, useRef } from 'react'; -import { DataLinkBuiltInVars, GrafanaTheme2, VariableOrigin, VariableSuggestion } from '@grafana/data'; +import { GrafanaTheme2, VariableSuggestion } from '@grafana/data'; -import { SlatePrism } from '../../slate-plugins/slate-prism'; -import { useStyles2 } from '../../themes/ThemeContext'; -import { getPositioningMiddleware } from '../../utils/floating'; -import { SCHEMA, makeValue } from '../../utils/slate'; +import { useStyles2, useTheme2 } from '../../themes/ThemeContext'; import { getInputStyles } from '../Input/Input'; -import { Portal } from '../Portal/Portal'; -import { ScrollContainer } from '../ScrollContainer/ScrollContainer'; -import { DataLinkSuggestions } from './DataLinkSuggestions'; -import { SelectionReference } from './SelectionReference'; - -const modulo = (a: number, n: number) => a - n * Math.floor(a / n); +import { createDataLinkHighlighter, createDataLinkTheme, dataLinkAutocompletion } from './codemirrorUtils'; interface DataLinkInputProps { value: string; @@ -30,49 +32,6 @@ interface DataLinkInputProps { placeholder?: string; } -const datalinksSyntax: Grammar = { - builtInVariable: { - pattern: /(\${\S+?})/, - }, -}; - -const plugins = [ - SlatePrism( - { - onlyIn: (node) => 'type' in node && node.type === 'code_block', - getSyntax: () => 'links', - }, - { ...(Prism.languages as LanguageMap), links: datalinksSyntax } - ), -]; - -const getStyles = (theme: GrafanaTheme2) => ({ - input: getInputStyles({ theme, invalid: false }).input, - editor: css({ - '.token.builtInVariable': { - color: theme.colors.success.text, - }, - '.token.variable': { - color: theme.colors.primary.text, - }, - }), - suggestionsWrapper: css({ - boxShadow: theme.shadows.z2, - }), - // Wrapper with child selector needed. - // When classnames are applied to the same element as the wrapper, it causes the suggestions to stop working - wrapperOverrides: css({ - width: '100%', - '> .slate-query-field__wrapper': { - padding: 0, - backgroundColor: 'transparent', - border: 'none', - }, - }), -}); - -// This memoised also because rerendering the slate editor grabs focus which created problem in some cases this -// was used and changes to different state were propagated here. export const DataLinkInput = memo( ({ value, @@ -80,168 +39,124 @@ export const DataLinkInput = memo( suggestions, placeholder = 'http://your-grafana.com/d/000000010/annotations', }: DataLinkInputProps) => { - const editorRef = useRef(null); + const editorContainerRef = useRef(null); + const editorViewRef = useRef(null); const styles = useStyles2(getStyles); - const [showingSuggestions, setShowingSuggestions] = useState(false); - const [suggestionsIndex, setSuggestionsIndex] = useState(0); - const [linkUrl, setLinkUrl] = useState(makeValue(value)); - const prevLinkUrl = usePrevious(linkUrl); - const [scrollTop, setScrollTop] = useState(0); - const scrollRef = useRef(null); + const theme = useTheme2(); + const themeCompartment = useRef(new Compartment()); + const suggestionsCompartment = useRef(new Compartment()); + const customKeymap = keymap.of([...closeBracketsKeymap, ...completionKeymap, ...historyKeymap, ...defaultKeymap]); + + // Initialize CodeMirror editor useEffect(() => { - scrollRef.current?.scrollTo(0, scrollTop); - }, [scrollTop]); - - // the order of middleware is important! - const middleware = [ - offset(({ rects }) => ({ - alignmentAxis: rects.reference.width, - })), - ...getPositioningMiddleware(), - ]; - - const { refs, floatingStyles } = useFloating({ - open: showingSuggestions, - placement: 'bottom-start', - onOpenChange: setShowingSuggestions, - middleware, - whileElementsMounted: autoUpdate, - strategy: 'fixed', - }); - - // Workaround for https://github.com/ianstormtaylor/slate/issues/2927 - const stateRef = useRef({ showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange }); - stateRef.current = { showingSuggestions, suggestions, suggestionsIndex, linkUrl, onChange }; - - // Used to get the height of the suggestion elements in order to scroll to them. - const activeRef = useRef(null); - useEffect(() => { - setScrollTop(getElementPosition(activeRef.current, suggestionsIndex)); - }, [suggestionsIndex]); - - const onKeyDown = React.useCallback((event: React.KeyboardEvent, next: () => void) => { - if (!stateRef.current.showingSuggestions) { - if (event.key === '=' || event.key === '$' || (event.keyCode === 32 && event.ctrlKey)) { - const selectionRef = new SelectionReference(); - refs.setReference(selectionRef); - return setShowingSuggestions(true); - } - return next(); + if (!editorContainerRef.current || editorViewRef.current) { + return; } - switch (event.key) { - case 'Backspace': - if (stateRef.current.linkUrl.focusText.getText().length === 1) { - next(); - } - case 'Escape': - setShowingSuggestions(false); - return setSuggestionsIndex(0); + const startState = EditorState.create({ + doc: value, + extensions: [ + lineNumbers(), + highlightActiveLine(), + highlightSpecialChars(), + history(), + foldGutter(), + drawSelection(), + dropCursor(), + EditorState.allowMultipleSelections.of(true), + indentOnInput(), + bracketMatching(), + closeBrackets(), + rectangularSelection(), + customKeymap, + placeholderExtension(placeholder), + EditorView.lineWrapping, + EditorView.updateListener.of((update: ViewUpdate) => { + if (update.docChanged) { + const newValue = update.state.doc.toString(); + onChange(newValue); + } + }), + tooltips({ + parent: document.body, // Render tooltips at body level to prevent clipping by modals + }), + themeCompartment.current.of([createDataLinkTheme(theme), createDataLinkHighlighter(theme)]), + suggestionsCompartment.current.of( + autocompletion({ + override: [dataLinkAutocompletion(suggestions)], + activateOnTyping: true, + closeOnBlur: true, + maxRenderedOptions: 100, + defaultKeymap: true, + interactionDelay: 0, + }) + ), + EditorState.phrases.of({ + next: 'Next', + previous: 'Previous', + Completions: 'Completions', + }), + EditorView.editorAttributes.of({ 'aria-label': placeholder }), + ], + }); - case 'Enter': - event.preventDefault(); - return onVariableSelect(stateRef.current.suggestions[stateRef.current.suggestionsIndex]); + const view = new EditorView({ + state: startState, + parent: editorContainerRef.current, + }); - case 'ArrowDown': - case 'ArrowUp': - event.preventDefault(); - const direction = event.key === 'ArrowDown' ? 1 : -1; - return setSuggestionsIndex((index) => modulo(index + direction, stateRef.current.suggestions.length)); - default: - return next(); - } + editorViewRef.current = view; + + return () => { + view.destroy(); + editorViewRef.current = null; + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Update editor value when prop changes useEffect(() => { - // Update the state of the link in the parent. This is basically done on blur but we need to do it after - // our state have been updated. The duplicity of state is done for perf reasons and also because local - // state also contains things like selection and formating. - if (prevLinkUrl && prevLinkUrl.selection.isFocused && !linkUrl.selection.isFocused) { - stateRef.current.onChange(Plain.serialize(linkUrl)); - } - }, [linkUrl, prevLinkUrl]); - - const onUrlChange = React.useCallback(({ value }: { value: Value }) => { - setLinkUrl(value); - }, []); - - const onVariableSelect = (item: VariableSuggestion, editor = editorRef.current!) => { - const precedingChar: string = getCharactersAroundCaret(); - const precedingDollar = precedingChar === '$'; - if (item.origin !== VariableOrigin.Template || item.value === DataLinkBuiltInVars.includeVars) { - editor.insertText(`${precedingDollar ? '' : '$'}\{${item.value}}`); - } else { - editor.insertText(`${precedingDollar ? '' : '$'}\{${item.value}:queryparam}`); - } - - setLinkUrl(editor.value); - setShowingSuggestions(false); - - setSuggestionsIndex(0); - stateRef.current.onChange(Plain.serialize(editor.value)); - }; - - const getCharactersAroundCaret = () => { - const input: HTMLSpanElement | null = document.getElementById('data-link-input')!; - let precedingChar = '', - sel: Selection | null, - range: Range; - if (window.getSelection) { - sel = window.getSelection(); - if (sel && sel.rangeCount > 0) { - range = sel.getRangeAt(0).cloneRange(); - // Collapse to the start of the range - range.collapse(true); - range.setStart(input, 0); - precedingChar = range.toString().slice(-1); + if (editorViewRef.current) { + const currentValue = editorViewRef.current.state.doc.toString(); + if (currentValue !== value) { + editorViewRef.current.dispatch({ + changes: { from: 0, to: currentValue.length, insert: value }, + }); } } - return precedingChar; - }; + }, [value]); + + // Update theme when it changes + useEffect(() => { + if (editorViewRef.current) { + editorViewRef.current.dispatch({ + effects: themeCompartment.current.reconfigure([createDataLinkTheme(theme), createDataLinkHighlighter(theme)]), + }); + } + }, [theme]); + + // Update suggestions when they change + useEffect(() => { + if (editorViewRef.current) { + editorViewRef.current.dispatch({ + effects: suggestionsCompartment.current.reconfigure( + autocompletion({ + override: [dataLinkAutocompletion(suggestions)], + activateOnTyping: true, + closeOnBlur: true, + maxRenderedOptions: 100, + defaultKeymap: true, + interactionDelay: 0, + }) + ), + }); + } + }, [suggestions]); return ( -
-
- -
+
+
); } @@ -249,6 +164,14 @@ export const DataLinkInput = memo( DataLinkInput.displayName = 'DataLinkInput'; -function getElementPosition(suggestionElement: HTMLElement | null, activeIndex: number) { - return (suggestionElement?.clientHeight ?? 0) * activeIndex; -} +const getStyles = (theme: GrafanaTheme2) => { + const baseInputStyles = getInputStyles({ theme, invalid: false }).input; + + return { + container: css({ + position: 'relative', + width: '100%', + }), + input: css(baseInputStyles), + }; +}; diff --git a/packages/grafana-ui/src/components/DataLinks/codemirrorUtils.test.ts b/packages/grafana-ui/src/components/DataLinks/codemirrorUtils.test.ts new file mode 100644 index 00000000000..d63a052c804 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/codemirrorUtils.test.ts @@ -0,0 +1,412 @@ +import { CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import { EditorState } from '@codemirror/state'; + +import { DataLinkBuiltInVars, VariableOrigin, VariableSuggestion } from '@grafana/data'; + +import { dataLinkAutocompletion } from './codemirrorUtils'; + +describe('dataLinkAutocompletion', () => { + const mockSuggestions: VariableSuggestion[] = [ + { + value: DataLinkBuiltInVars.seriesName, + label: '__series.name', + documentation: 'Series name', + origin: VariableOrigin.Series, + }, + { + value: DataLinkBuiltInVars.fieldName, + label: '__field.name', + documentation: 'Field name', + origin: VariableOrigin.Field, + }, + { + value: 'myVar', + label: 'myVar', + documentation: 'Custom variable', + origin: VariableOrigin.Template, + }, + { + value: DataLinkBuiltInVars.includeVars, + label: '__all_variables', + documentation: 'Include all variables', + origin: VariableOrigin.BuiltIn, + }, + ]; + + // Helper function to create a mock CompletionContext + function createMockContext(text: string, pos: number, explicit = false): CompletionContext { + const state = EditorState.create({ doc: text }); + + return { + state, + pos, + explicit, + matchBefore: (regex: RegExp) => { + const textBefore = text.slice(0, pos); + const match = textBefore.match(regex); + if (match) { + return { + from: pos - match[0].length, + to: pos, + text: match[0], + }; + } + return null; + }, + tokenBefore: jest.fn().mockReturnValue(null), + aborted: false, + addEventListener: jest.fn(), + }; + } + + describe('with no suggestions', () => { + it('should return null when suggestions array is empty', () => { + const autocompletion = dataLinkAutocompletion([]); + const context = createMockContext('$', 1); + const result = autocompletion(context); + + expect(result).toBeNull(); + }); + }); + + describe('explicit completion (Ctrl+Space)', () => { + it('should show all suggestions at cursor position when triggered explicitly', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('https://grafana.com', 19, true); + const result = autocompletion(context) as CompletionResult; + + expect(result).not.toBeNull(); + expect(result.from).toBe(19); + expect(result.options).toHaveLength(4); + }); + + it('should include proper labels and details for all suggestions', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('test', 4, true); + const result = autocompletion(context) as CompletionResult; + + expect(result.options[0]).toMatchObject({ + label: '__series.name', + detail: VariableOrigin.Series, + info: 'Series name', + type: 'variable', + }); + expect(result.options[1]).toMatchObject({ + label: '__field.name', + detail: VariableOrigin.Field, + info: 'Field name', + type: 'variable', + }); + }); + + it('should apply template variables with :queryparam suffix', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('test', 4, true); + const result = autocompletion(context) as CompletionResult; + + const templateVar = result.options.find((opt) => opt.label === 'myVar'); + expect(templateVar?.apply).toBe('${myVar:queryparam}'); + }); + + it('should apply non-template variables without :queryparam suffix', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('test', 4, true); + const result = autocompletion(context) as CompletionResult; + + const seriesVar = result.options.find((opt) => opt.label === '__series.name'); + expect(seriesVar?.apply).toBe(`\${${DataLinkBuiltInVars.seriesName}}`); + }); + + it('should apply includeVars without :queryparam suffix', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('test', 4, true); + const result = autocompletion(context) as CompletionResult; + + const includeVars = result.options.find((opt) => opt.label === '__all_variables'); + expect(includeVars?.apply).toBe(`\${${DataLinkBuiltInVars.includeVars}}`); + }); + }); + + describe('trigger character matching', () => { + it('should return null when no trigger character is present', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('https://grafana.com', 19); + const result = autocompletion(context); + + expect(result).toBeNull(); + }); + + it('should return null when text does not start with $ or =', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('test', 4); + const result = autocompletion(context); + + expect(result).toBeNull(); + }); + }); + + describe('$ trigger character', () => { + it('should show completions when $ is typed', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('$', 1); + const result = autocompletion(context) as CompletionResult; + + expect(result).not.toBeNull(); + expect(result.options).toHaveLength(4); + }); + + it('should show completions when ${ is typed', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('${', 2); + const result = autocompletion(context) as CompletionResult; + + expect(result).not.toBeNull(); + expect(result.options).toHaveLength(4); + }); + + it('should show completions when typing partial variable name', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('${my', 4); + const result = autocompletion(context) as CompletionResult; + + expect(result).not.toBeNull(); + expect(result.options).toHaveLength(4); + }); + + it('should show completions with dots in variable name', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('${__series.name', 15); + const result = autocompletion(context) as CompletionResult; + + expect(result).not.toBeNull(); + expect(result.options).toHaveLength(4); + }); + + it('should position completion from current position for single $', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('$', 1); + const result = autocompletion(context) as CompletionResult; + + expect(result.from).toBe(1); + }); + + it('should position completion from $ for partial match', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('${test', 6); + const result = autocompletion(context) as CompletionResult; + + expect(result.from).toBe(0); + }); + }); + + describe('= trigger character', () => { + it('should show completions when = is typed', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('=', 1); + const result = autocompletion(context) as CompletionResult; + + expect(result).not.toBeNull(); + expect(result.options).toHaveLength(4); + }); + + it('should show completions after = in URL query parameter', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('https://example.com?param=', 26); + const result = autocompletion(context) as CompletionResult; + + expect(result).not.toBeNull(); + expect(result.options).toHaveLength(4); + }); + + it('should position completion from current position for single =', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('=', 1); + const result = autocompletion(context) as CompletionResult; + + expect(result.from).toBe(1); + }); + }); + + describe('variable application', () => { + it('should use custom apply function for single character trigger', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('$', 1); + const result = autocompletion(context) as CompletionResult; + + expect(typeof result.options[0].apply).toBe('function'); + }); + + it('should use string apply for multi-character match', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('${test', 6); + const result = autocompletion(context) as CompletionResult; + + expect(typeof result.options[0].apply).toBe('string'); + }); + + it('should apply correct variable syntax for Series origin', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('${test', 6); + const result = autocompletion(context) as CompletionResult; + + const seriesVar = result.options.find((opt) => opt.label === '__series.name'); + expect(seriesVar?.apply).toBe(`\${${DataLinkBuiltInVars.seriesName}}`); + }); + + it('should apply correct variable syntax for Field origin', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('${test', 6); + const result = autocompletion(context) as CompletionResult; + + const fieldVar = result.options.find((opt) => opt.label === '__field.name'); + expect(fieldVar?.apply).toBe(`\${${DataLinkBuiltInVars.fieldName}}`); + }); + + it('should apply correct variable syntax for Template origin', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('${test', 6); + const result = autocompletion(context) as CompletionResult; + + const templateVar = result.options.find((opt) => opt.label === 'myVar'); + expect(templateVar?.apply).toBe('${myVar:queryparam}'); + }); + + it('should apply correct variable syntax for includeVars built-in', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('${test', 6); + const result = autocompletion(context) as CompletionResult; + + const includeVars = result.options.find((opt) => opt.label === '__all_variables'); + expect(includeVars?.apply).toBe(`\${${DataLinkBuiltInVars.includeVars}}`); + }); + }); + + describe('edge cases', () => { + it('should handle empty document', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('', 0); + const result = autocompletion(context); + + expect(result).toBeNull(); + }); + + it('should handle $ at the end of longer text', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('https://grafana.com?var=$', 25); + const result = autocompletion(context) as CompletionResult; + + expect(result).not.toBeNull(); + expect(result.from).toBe(25); + }); + + it('should handle = at the end of longer text', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('https://grafana.com?var=', 24); + const result = autocompletion(context) as CompletionResult; + + expect(result).not.toBeNull(); + expect(result.from).toBe(24); + }); + + it('should handle mixed content with ${ in the middle', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('https://grafana.com?var=${', 26); + const result = autocompletion(context) as CompletionResult; + + expect(result).not.toBeNull(); + expect(result.options).toHaveLength(4); + }); + + it('should handle single suggestion', () => { + const singleSuggestion: VariableSuggestion[] = [ + { + value: 'test', + label: 'test', + documentation: 'Test variable', + origin: VariableOrigin.Template, + }, + ]; + const autocompletion = dataLinkAutocompletion(singleSuggestion); + const context = createMockContext('$', 1); + const result = autocompletion(context) as CompletionResult; + + expect(result).not.toBeNull(); + expect(result.options).toHaveLength(1); + expect(result.options[0].label).toBe('test'); + }); + + it('should include all metadata fields in completion options', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('$', 1); + const result = autocompletion(context) as CompletionResult; + + result.options.forEach((option) => { + expect(option).toHaveProperty('label'); + expect(option).toHaveProperty('detail'); + expect(option).toHaveProperty('info'); + expect(option).toHaveProperty('apply'); + expect(option).toHaveProperty('type'); + expect(option.type).toBe('variable'); + }); + }); + }); + + describe('completion context states', () => { + it('should handle explicit completion in middle of text', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('https://example.com', 10, true); + const result = autocompletion(context) as CompletionResult; + + expect(result).not.toBeNull(); + expect(result.from).toBe(10); + expect(result.options).toHaveLength(4); + }); + + it('should handle explicit completion at start of document', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('test', 0, true); + const result = autocompletion(context) as CompletionResult; + + expect(result).not.toBeNull(); + expect(result.from).toBe(0); + expect(result.options).toHaveLength(4); + }); + + it('should not show completions for text without trigger when not explicit', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('test', 4); + const result = autocompletion(context); + + expect(result).toBeNull(); + }); + }); + + describe('multiple variables in text', () => { + it('should handle completion after existing variable', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('${myVar}$', 9); + const result = autocompletion(context) as CompletionResult; + + expect(result).not.toBeNull(); + expect(result.from).toBe(9); + }); + + it('should handle completion between variables', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('${var1}$${var2}', 8); + const result = autocompletion(context) as CompletionResult; + + expect(result).not.toBeNull(); + expect(result.from).toBe(8); + }); + + it('should handle completion in URL with multiple query params', () => { + const autocompletion = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('?a=${var1}&b=$', 14); + const result = autocompletion(context) as CompletionResult; + + expect(result).not.toBeNull(); + expect(result.from).toBe(14); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/DataLinks/codemirrorUtils.ts b/packages/grafana-ui/src/components/DataLinks/codemirrorUtils.ts new file mode 100644 index 00000000000..16fc4d643f7 --- /dev/null +++ b/packages/grafana-ui/src/components/DataLinks/codemirrorUtils.ts @@ -0,0 +1,241 @@ +import { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import { Extension } from '@codemirror/state'; +import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view'; + +import { DataLinkBuiltInVars, GrafanaTheme2, VariableOrigin, VariableSuggestion } from '@grafana/data'; + +/** + * Creates a CodeMirror theme for data link input based on Grafana's theme + */ +export function createDataLinkTheme(theme: GrafanaTheme2): Extension { + const isDark = theme.colors.mode === 'dark'; + + return EditorView.theme( + { + '&': { + fontSize: theme.typography.body.fontSize, + fontFamily: theme.typography.fontFamilyMonospace, + backgroundColor: 'transparent', + border: 'none', + outline: 'none', + }, + '.cm-placeholder': { + color: theme.colors.text.disabled, + fontStyle: 'normal', + }, + '.cm-scroller': { + overflow: 'auto', + fontFamily: theme.typography.fontFamilyMonospace, + }, + '.cm-content': { + padding: '3px 0', + color: theme.colors.text.primary, + caretColor: theme.colors.text.primary, + }, + '.cm-line': { + padding: '0 2px', + }, + '.cm-cursor': { + borderLeftColor: theme.colors.text.primary, + }, + '.cm-selectionBackground': { + backgroundColor: `${theme.colors.action.selected} !important`, + }, + '&.cm-focused .cm-selectionBackground': { + backgroundColor: `${theme.colors.action.focus} !important`, + }, + '.cm-variable': { + color: theme.colors.success.text, + fontWeight: theme.typography.fontWeightMedium, + }, + '.cm-activeLine': { + backgroundColor: 'transparent', + }, + '.cm-gutters': { + display: 'none', + }, + '.cm-tooltip': { + zIndex: theme.zIndex.portal + 1, // Above modals and portals (1062) + }, + '.cm-tooltip.cm-tooltip-autocomplete': { + backgroundColor: theme.colors.background.primary, + border: `1px solid ${theme.colors.border.weak}`, + boxShadow: theme.shadows.z3, + }, + '.cm-tooltip.cm-tooltip-autocomplete > ul': { + fontFamily: theme.typography.fontFamily, + maxHeight: '300px', + }, + '.cm-tooltip.cm-tooltip-autocomplete > ul > li': { + padding: '2px 8px', + color: theme.colors.text.primary, + }, + '.cm-tooltip-autocomplete ul li[aria-selected]': { + backgroundColor: theme.colors.background.secondary, + color: theme.colors.text.primary, + }, + '.cm-completionLabel': { + fontFamily: theme.typography.fontFamilyMonospace, + fontSize: theme.typography.size.sm, + }, + '.cm-completionDetail': { + color: theme.colors.text.secondary, + fontStyle: 'normal', + marginLeft: theme.spacing(1), + }, + '.cm-completionInfo': { + backgroundColor: theme.colors.background.primary, + border: `1px solid ${theme.colors.border.weak}`, + color: theme.colors.text.primary, + padding: theme.spacing(1), + }, + }, + { dark: isDark } + ); +} + +/** + * Creates a syntax highlighter for data link variables (${...}) + * Matches the pattern from the old Prism implementation: (\${\S+?}) + */ +export function createDataLinkHighlighter(theme: GrafanaTheme2): Extension { + // Regular expression matching ${...} patterns (same as old implementation) + const variablePattern = /\$\{[^}]+\}/g; + + const variableDecoration = Decoration.mark({ + class: 'cm-variable', + }); + + const viewPlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = this.buildDecorations(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = this.buildDecorations(update.view); + } + } + + buildDecorations(view: EditorView): DecorationSet { + const decorations: Array<{ from: number; to: number }> = []; + const text = view.state.doc.toString(); + let match; + + // Reset regex state + variablePattern.lastIndex = 0; + + while ((match = variablePattern.exec(text)) !== null) { + decorations.push({ + from: match.index, + to: match.index + match[0].length, + }); + } + + return Decoration.set(decorations.map((range) => variableDecoration.range(range.from, range.to))); + } + }, + { + decorations: (v) => v.decorations, + } + ); + + return viewPlugin; +} + +/** + * Creates autocomplete function for data link variables + * Triggers on $ and = characters + */ +export function dataLinkAutocompletion( + suggestions: VariableSuggestion[] +): (context: CompletionContext) => CompletionResult | null { + return (context: CompletionContext): CompletionResult | null => { + // Match $ or = followed by optional { and word characters + // This will match: $, ${, ${word, =, etc. + const word = context.matchBefore(/[$=]\{?[\w.]*$/); + + // Don't show completions if there are no suggestions + if (suggestions.length === 0) { + return null; + } + + // For explicit completion (Ctrl+Space), show at cursor position + if (context.explicit) { + const options: Completion[] = suggestions.map((suggestion) => { + let applyText: string; + + if (suggestion.origin !== VariableOrigin.Template || suggestion.value === DataLinkBuiltInVars.includeVars) { + applyText = `\${${suggestion.value}}`; + } else { + applyText = `\${${suggestion.value}:queryparam}`; + } + + return { + label: suggestion.label, + detail: suggestion.origin, + info: suggestion.documentation, + apply: applyText, + type: 'variable', + }; + }); + + return { + from: context.pos, + options, + }; + } + + // If no match on typing, don't show completions + if (!word) { + return null; + } + + // Check if the match starts with a trigger character + const triggerChar = word.text.charAt(0); + if (triggerChar !== '$' && triggerChar !== '=') { + return null; + } + + // For single trigger character ($ or =), start from current position to show completions + // But the 'apply' text will still replace correctly + const isSingleChar = word.text.length === 1; + + const options: Completion[] = suggestions.map((suggestion) => { + // Always insert the full variable syntax + let applyText: string; + + if (suggestion.origin !== VariableOrigin.Template || suggestion.value === DataLinkBuiltInVars.includeVars) { + applyText = `\${${suggestion.value}}`; + } else { + applyText = `\${${suggestion.value}:queryparam}`; + } + + return { + label: suggestion.label, + detail: suggestion.origin, + info: suggestion.documentation, + // Use a custom apply function to handle replacement properly + apply: isSingleChar + ? (view, completion, from, to) => { + // Replace from the trigger character position + let wordFrom = triggerChar === '=' ? context.pos : word.from; + view.dispatch({ + changes: { from: wordFrom, to, insert: applyText }, + selection: { anchor: wordFrom + applyText.length }, // Move cursor to end of inserted text + }); + } + : applyText, + type: 'variable', + }; + }); + + return { + from: isSingleChar ? context.pos : word.from, + options, + }; + }; +} diff --git a/yarn.lock b/yarn.lock index 1b4710e4062..e8edad4f9cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1572,6 +1572,65 @@ __metadata: languageName: node linkType: hard +"@codemirror/autocomplete@npm:^6.12.0": + version: 6.20.0 + resolution: "@codemirror/autocomplete@npm:6.20.0" + dependencies: + "@codemirror/language": "npm:^6.0.0" + "@codemirror/state": "npm:^6.0.0" + "@codemirror/view": "npm:^6.17.0" + "@lezer/common": "npm:^1.0.0" + checksum: 10/ba3603b860c30dd4f8b7c20085680d2f491022db95fe1f3aa6a58363c64678efb3ba795d715755c8a02121631317cf7fbe44cfa3b4cdb01ebca2b4ed36ea5d8a + languageName: node + linkType: hard + +"@codemirror/commands@npm:^6.3.3": + version: 6.10.1 + resolution: "@codemirror/commands@npm:6.10.1" + dependencies: + "@codemirror/language": "npm:^6.0.0" + "@codemirror/state": "npm:^6.4.0" + "@codemirror/view": "npm:^6.27.0" + "@lezer/common": "npm:^1.1.0" + checksum: 10/9e305263dc457635fa1c7e5b47756958be5367e38f5bb07a3abfd5966591e2eafd57ea0c5c738b28bb3ab5de64c07a5302ebd49b129ff7e48b225841f66e647f + languageName: node + linkType: hard + +"@codemirror/language@npm:^6.0.0, @codemirror/language@npm:^6.10.0": + version: 6.12.1 + resolution: "@codemirror/language@npm:6.12.1" + dependencies: + "@codemirror/state": "npm:^6.0.0" + "@codemirror/view": "npm:^6.23.0" + "@lezer/common": "npm:^1.5.0" + "@lezer/highlight": "npm:^1.0.0" + "@lezer/lr": "npm:^1.0.0" + style-mod: "npm:^4.0.0" + checksum: 10/a24c3512d38cbb2a20cc3128da0eea074b4a6102b6a5a041b3dfd5e67638fb61dcdf4743ed87708db882df5d72a84d9f891aac6fa68447830989c8e2d9ffa2ba + languageName: node + linkType: hard + +"@codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.4.0, @codemirror/state@npm:^6.5.0": + version: 6.5.3 + resolution: "@codemirror/state@npm:6.5.3" + dependencies: + "@marijn/find-cluster-break": "npm:^1.0.0" + checksum: 10/07dc8e06aa3c78bde36fd584d1e1131a529d244474dd36bffc6ad1033701d6628a02259711692d099b2a482ede015930f20106aa8ebc7b251db6f303bc72caa2 + languageName: node + linkType: hard + +"@codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0": + version: 6.39.9 + resolution: "@codemirror/view@npm:6.39.9" + dependencies: + "@codemirror/state": "npm:^6.5.0" + crelt: "npm:^1.0.6" + style-mod: "npm:^4.1.0" + w3c-keyname: "npm:^2.2.4" + checksum: 10/9e86b35f31fd4f8b4c2fe608fa6116ddc71261acd842c405de41de1f752268c47ea8e0c400818b4d0481a629e1f773dda9e6f0d24d38ed6a9f6b3d58b2dff669 + languageName: node + linkType: hard + "@colors/colors@npm:1.5.0": version: 1.5.0 resolution: "@colors/colors@npm:1.5.0" @@ -3770,6 +3829,11 @@ __metadata: resolution: "@grafana/ui@workspace:packages/grafana-ui" dependencies: "@babel/core": "npm:7.28.0" + "@codemirror/autocomplete": "npm:^6.12.0" + "@codemirror/commands": "npm:^6.3.3" + "@codemirror/language": "npm:^6.10.0" + "@codemirror/state": "npm:^6.4.0" + "@codemirror/view": "npm:^6.23.0" "@emotion/css": "npm:11.13.5" "@emotion/react": "npm:11.14.0" "@emotion/serialize": "npm:1.3.3" @@ -3781,6 +3845,7 @@ __metadata: "@grafana/i18n": "npm:12.4.0-pre" "@grafana/schema": "npm:12.4.0-pre" "@hello-pangea/dnd": "npm:18.0.1" + "@lezer/highlight": "npm:^1.2.0" "@monaco-editor/react": "npm:4.7.0" "@popperjs/core": "npm:2.11.8" "@rc-component/drawer": "npm:1.3.0" @@ -5192,7 +5257,14 @@ __metadata: languageName: node linkType: hard -"@lezer/highlight@npm:1.2.3": +"@lezer/common@npm:^1.1.0, @lezer/common@npm:^1.5.0": + version: 1.5.0 + resolution: "@lezer/common@npm:1.5.0" + checksum: 10/d99a45947c5033476f7c16f475b364e5b276e89a351641d8d785ceac88e8175f7b7b7d43dda80c3d9097f5e3379f018404bbe59a41d15992df23a03bbef3519b + languageName: node + linkType: hard + +"@lezer/highlight@npm:1.2.3, @lezer/highlight@npm:^1.0.0, @lezer/highlight@npm:^1.2.0": version: 1.2.3 resolution: "@lezer/highlight@npm:1.2.3" dependencies: @@ -5210,6 +5282,15 @@ __metadata: languageName: node linkType: hard +"@lezer/lr@npm:^1.0.0": + version: 1.4.7 + resolution: "@lezer/lr@npm:1.4.7" + dependencies: + "@lezer/common": "npm:^1.0.0" + checksum: 10/5407e10c8f983eedd8eaace9f2582aac39f7b280cdcf4e396d53ca6c1e654ce1bb2fdbddfbf9a63c8462046be37c8c4da180be7ffaf2d2aa24eb71622f624d85 + languageName: node + linkType: hard + "@linaria/core@npm:^4.5.4": version: 4.5.4 resolution: "@linaria/core@npm:4.5.4" @@ -5387,6 +5468,13 @@ __metadata: languageName: node linkType: hard +"@marijn/find-cluster-break@npm:^1.0.0": + version: 1.0.2 + resolution: "@marijn/find-cluster-break@npm:1.0.2" + checksum: 10/92fe7ba43ce3d3314f593e4c2fd822d7089649baff47a474fe04b83e3119931d7cf58388747d429ff65fa2db14f5ca57e787268c482e868fc67759511f61f09b + languageName: node + linkType: hard + "@mdx-js/react@npm:^3.0.0": version: 3.0.1 resolution: "@mdx-js/react@npm:3.0.1" @@ -14930,6 +15018,13 @@ __metadata: languageName: node linkType: hard +"crelt@npm:^1.0.6": + version: 1.0.6 + resolution: "crelt@npm:1.0.6" + checksum: 10/5ed326ca6bd243b1dba6b943f665b21c2c04be03271824bc48f20dba324b0f8233e221f8c67312526d24af2b1243c023dc05a41bd8bd05d1a479fd2c72fb39c3 + languageName: node + linkType: hard + "croact-css-styled@npm:^1.1.9": version: 1.1.9 resolution: "croact-css-styled@npm:1.1.9" @@ -31798,6 +31893,13 @@ __metadata: languageName: node linkType: hard +"style-mod@npm:^4.0.0, style-mod@npm:^4.1.0": + version: 4.1.3 + resolution: "style-mod@npm:4.1.3" + checksum: 10/b47465ea953c42e62682a2a366a0946a4aa973cbabb000619acbf5d1c162c94aa019caeb13804e38bed71c2b19b8c778f847542d7e82e9309154ccbb5ef9ca98 + languageName: node + linkType: hard + "style-search@npm:^0.1.0": version: 0.1.0 resolution: "style-search@npm:0.1.0" @@ -33839,6 +33941,13 @@ __metadata: languageName: node linkType: hard +"w3c-keyname@npm:^2.2.4": + version: 2.2.8 + resolution: "w3c-keyname@npm:2.2.8" + checksum: 10/95bafa4c04fa2f685a86ca1000069c1ec43ace1f8776c10f226a73296caeddd83f893db885c2c220ebeb6c52d424e3b54d7c0c1e963bbf204038ff1a944fbb07 + languageName: node + linkType: hard + "w3c-xmlserializer@npm:^3.0.0": version: 3.0.0 resolution: "w3c-xmlserializer@npm:3.0.0"