diff --git a/packages/grafana-ui/src/components/CodeMirror/CodeMirrorEditor.test.tsx b/packages/grafana-ui/src/components/CodeMirror/CodeMirrorEditor.test.tsx new file mode 100644 index 00000000000..30562740707 --- /dev/null +++ b/packages/grafana-ui/src/components/CodeMirror/CodeMirrorEditor.test.tsx @@ -0,0 +1,404 @@ +import { Extension } from '@codemirror/state'; +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 { createTheme, GrafanaTheme2 } from '@grafana/data'; + +import { CodeMirrorEditor } from './CodeMirrorEditor'; +import { createGenericHighlighter } from './highlight'; +import { createGenericTheme } from './styles'; +import { HighlighterFactory, SyntaxHighlightConfig, ThemeFactory } from './types'; + +// Mock DOM elements required by CodeMirror +beforeAll(() => { + Range.prototype.getClientRects = jest.fn(() => ({ + item: () => null, + length: 0, + [Symbol.iterator]: jest.fn(), + })); + Range.prototype.getBoundingClientRect = jest.fn(() => ({ + x: 0, + y: 0, + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + toJSON: () => {}, + })); +}); + +describe('CodeMirrorEditor', () => { + describe('basic rendering', () => { + 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 text here'; + + render(); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toHaveAttribute('aria-placeholder', placeholder); + }); + }); + + it('renders with aria-label', async () => { + const onChange = jest.fn(); + const ariaLabel = 'Code editor'; + + render(); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + // aria-label is set on the parent .cm-editor element + expect(editor.closest('.cm-editor')).toHaveAttribute('aria-label', ariaLabel); + }); + }); + }); + + describe('user interaction', () => { + it('calls onChange when user types', 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('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(); + }); + }); + }); + + describe('highlight functionality', () => { + it('renders with default highlighter using highlightConfig', async () => { + const onChange = jest.fn(); + const highlightConfig: SyntaxHighlightConfig = { + pattern: /\$\{[^}]+\}/g, + className: 'variable-highlight', + }; + + render(); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toBeInTheDocument(); + }); + }); + + it('renders with custom highlighter factory', async () => { + const onChange = jest.fn(); + const customHighlighter: HighlighterFactory = (config) => { + return config ? createGenericHighlighter(config) : []; + }; + const highlightConfig: SyntaxHighlightConfig = { + pattern: /\btest\b/g, + className: 'keyword', + }; + + render( + + ); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toBeInTheDocument(); + }); + }); + + it('updates highlights when highlightConfig changes', async () => { + const onChange = jest.fn(); + + function TestWrapper({ pattern }: { pattern: RegExp }) { + const [config, setConfig] = React.useState({ + pattern, + className: 'highlight', + }); + + useEffect(() => { + setConfig({ pattern, className: 'highlight' }); + }, [pattern]); + + return ; + } + + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + rerender(); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toBeInTheDocument(); + }); + }); + + it('renders without highlighting when highlightConfig is not provided', async () => { + const onChange = jest.fn(); + + render(); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toBeInTheDocument(); + }); + }); + }); + + describe('theme functionality', () => { + it('renders with default theme', async () => { + const onChange = jest.fn(); + + render(); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toBeInTheDocument(); + }); + }); + + it('renders with custom theme factory', async () => { + const onChange = jest.fn(); + const customTheme: ThemeFactory = (theme) => { + return createGenericTheme(theme); + }; + + render(); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toBeInTheDocument(); + }); + }); + + it('updates theme when themeFactory changes', async () => { + const onChange = jest.fn(); + const theme1: ThemeFactory = (theme) => createGenericTheme(theme); + const theme2: ThemeFactory = (theme) => createGenericTheme(theme); + + function TestWrapper({ themeFactory }: { themeFactory: ThemeFactory }) { + return ; + } + + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + rerender(); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toBeInTheDocument(); + }); + }); + }); + + describe('combined highlight and theme', () => { + it('renders with both custom theme and highlighter', async () => { + const onChange = jest.fn(); + const customTheme: ThemeFactory = (theme) => createGenericTheme(theme); + const highlightConfig: SyntaxHighlightConfig = { + pattern: /\$\{[^}]+\}/g, + className: 'variable', + }; + + render( + + ); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toBeInTheDocument(); + }); + }); + + it('updates both theme and highlights together', async () => { + const onChange = jest.fn(); + + function TestWrapper({ pattern, mode }: { pattern: RegExp; mode: 'light' | 'dark' }) { + const [config, setConfig] = React.useState({ + pattern, + className: 'highlight', + }); + const [themeFactory, setThemeFactory] = React.useState( + () => (theme: GrafanaTheme2) => createGenericTheme(theme) + ); + + useEffect(() => { + setConfig({ pattern, className: 'highlight' }); + setThemeFactory(() => (theme: GrafanaTheme2) => { + const customTheme = createTheme({ colors: { mode } }); + return createGenericTheme(customTheme); + }); + }, [pattern, mode]); + + return ( + + ); + } + + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + rerender(); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toBeInTheDocument(); + }); + }); + }); + + describe('additional features with highlight and theme', () => { + it('renders with showLineNumbers and highlighting', async () => { + const onChange = jest.fn(); + const highlightConfig: SyntaxHighlightConfig = { + pattern: /\d+/g, + className: 'number', + }; + + render( + + ); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toBeInTheDocument(); + }); + }); + + it('renders with custom extensions alongside theme and highlighter', async () => { + const onChange = jest.fn(); + const customExtension: Extension[] = []; + const highlightConfig: SyntaxHighlightConfig = { + pattern: /test/g, + className: 'keyword', + }; + + render( + + ); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toBeInTheDocument(); + }); + }); + + it('applies custom className with theme', async () => { + const onChange = jest.fn(); + const customClassName = 'custom-editor'; + + render(); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toBeInTheDocument(); + }); + }); + }); + + describe('useInputStyles prop', () => { + it('renders with input styles enabled', async () => { + const onChange = jest.fn(); + + render(); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toBeInTheDocument(); + }); + }); + + it('renders with input styles disabled', async () => { + const onChange = jest.fn(); + + render(); + + await waitFor(() => { + const editor = screen.getByRole('textbox'); + expect(editor).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/CodeMirror/highlight.test.ts b/packages/grafana-ui/src/components/CodeMirror/highlight.test.ts new file mode 100644 index 00000000000..2c6e506b063 --- /dev/null +++ b/packages/grafana-ui/src/components/CodeMirror/highlight.test.ts @@ -0,0 +1,246 @@ +import { EditorState } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; + +import { createGenericHighlighter } from './highlight'; +import { SyntaxHighlightConfig } from './types'; + +// Mock DOM elements required by CodeMirror +beforeAll(() => { + Range.prototype.getClientRects = jest.fn(() => ({ + item: () => null, + length: 0, + [Symbol.iterator]: jest.fn(), + })); + Range.prototype.getBoundingClientRect = jest.fn(() => ({ + x: 0, + y: 0, + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + toJSON: () => {}, + })); +}); + +describe('createGenericHighlighter', () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + /** + * Helper to create editor with highlighter + */ + function createEditorWithHighlighter(config: SyntaxHighlightConfig, text: string) { + const highlighter = createGenericHighlighter(config); + const state = EditorState.create({ + doc: text, + extensions: [highlighter], + }); + return new EditorView({ state, parent: container }); + } + + describe('basic highlighting', () => { + it('highlights text matching the pattern', () => { + const config: SyntaxHighlightConfig = { + pattern: /\$\{[^}]+\}/g, + className: 'test-highlight', + }; + + const view = createEditorWithHighlighter(config, 'Hello ${world}!'); + const content = view.dom.textContent; + + expect(content).toBe('Hello ${world}!'); + view.destroy(); + }); + + it('highlights multiple matches', () => { + const config: SyntaxHighlightConfig = { + pattern: /\$\{[^}]+\}/g, + className: 'variable', + }; + + const view = createEditorWithHighlighter(config, '${first} and ${second} and ${third}'); + const content = view.dom.textContent; + + expect(content).toBe('${first} and ${second} and ${third}'); + view.destroy(); + }); + + it('handles text with no matches', () => { + const config: SyntaxHighlightConfig = { + pattern: /\$\{[^}]+\}/g, + className: 'variable', + }; + + const view = createEditorWithHighlighter(config, 'No variables here'); + const content = view.dom.textContent; + + expect(content).toBe('No variables here'); + view.destroy(); + }); + + it('handles empty text', () => { + const config: SyntaxHighlightConfig = { + pattern: /\$\{[^}]+\}/g, + className: 'variable', + }; + + const view = createEditorWithHighlighter(config, ''); + const content = view.dom.textContent; + + expect(content).toBe(''); + view.destroy(); + }); + }); + + describe('pattern variations', () => { + it('highlights with simple word pattern', () => { + const config: SyntaxHighlightConfig = { + pattern: /\btest\b/g, + className: 'keyword', + }; + + const view = createEditorWithHighlighter(config, 'This is a test of the test word'); + const content = view.dom.textContent; + + expect(content).toBe('This is a test of the test word'); + view.destroy(); + }); + + it('highlights with number pattern', () => { + const config: SyntaxHighlightConfig = { + pattern: /\d+/g, + className: 'number', + }; + + const view = createEditorWithHighlighter(config, 'Numbers: 123, 456, 789'); + const content = view.dom.textContent; + + expect(content).toBe('Numbers: 123, 456, 789'); + view.destroy(); + }); + + it('highlights with URL pattern', () => { + const config: SyntaxHighlightConfig = { + pattern: /https?:\/\/[^\s]+/g, + className: 'url', + }; + + const view = createEditorWithHighlighter(config, 'Visit https://grafana.com and http://example.com'); + const content = view.dom.textContent; + + expect(content).toBe('Visit https://grafana.com and http://example.com'); + view.destroy(); + }); + }); + + describe('dynamic updates', () => { + it('updates highlights when document changes', () => { + const config: SyntaxHighlightConfig = { + pattern: /\$\{[^}]+\}/g, + className: 'variable', + }; + + const view = createEditorWithHighlighter(config, 'Initial text'); + + // Update document + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: 'New ${variable} text' }, + }); + + const content = view.dom.textContent; + expect(content).toBe('New ${variable} text'); + view.destroy(); + }); + + it('updates highlights when adding to document', () => { + const config: SyntaxHighlightConfig = { + pattern: /\$\{[^}]+\}/g, + className: 'variable', + }; + + const view = createEditorWithHighlighter(config, 'Start '); + + // Insert text + view.dispatch({ + changes: { from: view.state.doc.length, insert: '${var}' }, + }); + + const content = view.dom.textContent; + expect(content).toBe('Start ${var}'); + view.destroy(); + }); + + it('removes highlights when pattern no longer matches', () => { + const config: SyntaxHighlightConfig = { + pattern: /\$\{[^}]+\}/g, + className: 'variable', + }; + + const view = createEditorWithHighlighter(config, '${variable}'); + + // Replace with non-matching text + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: 'plain text' }, + }); + + const content = view.dom.textContent; + expect(content).toBe('plain text'); + view.destroy(); + }); + }); + + describe('complex patterns', () => { + it('highlights nested brackets', () => { + const config: SyntaxHighlightConfig = { + pattern: /\$\{[^}]+\}/g, + className: 'variable', + }; + + const view = createEditorWithHighlighter(config, 'Text with ${var1} and ${var2} variables'); + const content = view.dom.textContent; + + expect(content).toBe('Text with ${var1} and ${var2} variables'); + view.destroy(); + }); + + it('highlights overlapping patterns correctly', () => { + const config: SyntaxHighlightConfig = { + pattern: /test/g, + className: 'keyword', + }; + + const view = createEditorWithHighlighter(config, 'testtesttest'); + const content = view.dom.textContent; + + expect(content).toBe('testtesttest'); + view.destroy(); + }); + }); + + describe('multiline text', () => { + it('highlights patterns across multiple lines', () => { + const config: SyntaxHighlightConfig = { + pattern: /\$\{[^}]+\}/g, + className: 'variable', + }; + + const text = 'Line 1 ${var1}\nLine 2 ${var2}\nLine 3'; + const view = createEditorWithHighlighter(config, text); + + // Check the document state instead of textContent (which doesn't preserve newlines in DOM) + const docContent = view.state.doc.toString(); + expect(docContent).toBe(text); + view.destroy(); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/CodeMirror/styles.test.ts b/packages/grafana-ui/src/components/CodeMirror/styles.test.ts new file mode 100644 index 00000000000..ec9fa1f7bc4 --- /dev/null +++ b/packages/grafana-ui/src/components/CodeMirror/styles.test.ts @@ -0,0 +1,189 @@ +import { Compartment, EditorState } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; + +import { createTheme } from '@grafana/data'; + +import { createGenericTheme } from './styles'; + +// Mock DOM elements required by CodeMirror +beforeAll(() => { + Range.prototype.getClientRects = jest.fn(() => ({ + item: () => null, + length: 0, + [Symbol.iterator]: jest.fn(), + })); + Range.prototype.getBoundingClientRect = jest.fn(() => ({ + x: 0, + y: 0, + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + toJSON: () => {}, + })); +}); + +describe('createGenericTheme', () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + /** + * Helper to create editor with theme + */ + function createEditorWithTheme(themeMode: 'light' | 'dark', text = 'test') { + const theme = createTheme({ colors: { mode: themeMode } }); + const themeExtension = createGenericTheme(theme); + const state = EditorState.create({ + doc: text, + extensions: [themeExtension], + }); + return new EditorView({ state, parent: container }); + } + + describe('theme creation', () => { + it('creates theme for light mode', () => { + const theme = createTheme({ colors: { mode: 'light' } }); + const themeExtension = createGenericTheme(theme); + + expect(themeExtension).toBeDefined(); + }); + + it('creates theme for dark mode', () => { + const theme = createTheme({ colors: { mode: 'dark' } }); + const themeExtension = createGenericTheme(theme); + + expect(themeExtension).toBeDefined(); + }); + + it('applies theme to editor in light mode', () => { + const view = createEditorWithTheme('light'); + + expect(view).toBeDefined(); + expect(view.dom).toBeInstanceOf(HTMLElement); + view.destroy(); + }); + + it('applies theme to editor in dark mode', () => { + const view = createEditorWithTheme('dark'); + + expect(view).toBeDefined(); + expect(view.dom).toBeInstanceOf(HTMLElement); + view.destroy(); + }); + }); + + describe('theme properties', () => { + it('applies typography settings from theme', () => { + const theme = createTheme({ colors: { mode: 'light' } }); + const themeExtension = createGenericTheme(theme); + + const state = EditorState.create({ + doc: 'test', + extensions: [themeExtension], + }); + const view = new EditorView({ state, parent: container }); + + // Check that editor is created successfully + expect(view.dom).toBeInstanceOf(HTMLElement); + view.destroy(); + }); + + it('applies color settings from theme', () => { + const theme = createTheme({ colors: { mode: 'dark' } }); + const themeExtension = createGenericTheme(theme); + + const state = EditorState.create({ + doc: 'test', + extensions: [themeExtension], + }); + const view = new EditorView({ state, parent: container }); + + expect(view.dom).toBeInstanceOf(HTMLElement); + view.destroy(); + }); + }); + + describe('theme updates', () => { + it('switches from light to dark theme', () => { + const themeCompartment = new Compartment(); + const lightTheme = createTheme({ colors: { mode: 'light' } }); + const lightThemeExtension = createGenericTheme(lightTheme); + + const state = EditorState.create({ + doc: 'test', + extensions: [themeCompartment.of(lightThemeExtension)], + }); + const view = new EditorView({ state, parent: container }); + + // Update to dark theme + const darkTheme = createTheme({ colors: { mode: 'dark' } }); + const darkThemeExtension = createGenericTheme(darkTheme); + + view.dispatch({ + effects: themeCompartment.reconfigure(darkThemeExtension), + }); + + expect(view.dom).toBeInstanceOf(HTMLElement); + view.destroy(); + }); + + it('switches from dark to light theme', () => { + const themeCompartment = new Compartment(); + const darkTheme = createTheme({ colors: { mode: 'dark' } }); + const darkThemeExtension = createGenericTheme(darkTheme); + + const state = EditorState.create({ + doc: 'test', + extensions: [themeCompartment.of(darkThemeExtension)], + }); + const view = new EditorView({ state, parent: container }); + + // Update to light theme + const lightTheme = createTheme({ colors: { mode: 'light' } }); + const lightThemeExtension = createGenericTheme(lightTheme); + + view.dispatch({ + effects: themeCompartment.reconfigure(lightThemeExtension), + }); + + expect(view.dom).toBeInstanceOf(HTMLElement); + view.destroy(); + }); + }); + + describe('editor rendering', () => { + it('renders editor with light theme and content', () => { + const view = createEditorWithTheme('light', 'Hello world!'); + + expect(view.dom).toHaveTextContent('Hello world!'); + view.destroy(); + }); + + it('renders editor with dark theme and content', () => { + const view = createEditorWithTheme('dark', 'Hello world!'); + + expect(view.dom).toHaveTextContent('Hello world!'); + view.destroy(); + }); + + it('renders multiline content with theme', () => { + const text = 'Line 1\nLine 2\nLine 3'; + const view = createEditorWithTheme('light', text); + + // Check the document state instead of textContent (which doesn't preserve newlines in DOM) + const docContent = view.state.doc.toString(); + expect(docContent).toBe(text); + view.destroy(); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/DataLinks/codemirrorUtils.test.ts b/packages/grafana-ui/src/components/DataLinks/codemirrorUtils.test.ts index d63a052c804..889b4bb3ece 100644 --- a/packages/grafana-ui/src/components/DataLinks/codemirrorUtils.test.ts +++ b/packages/grafana-ui/src/components/DataLinks/codemirrorUtils.test.ts @@ -1,412 +1,480 @@ -import { CompletionContext, CompletionResult } from '@codemirror/autocomplete'; -import { EditorState } from '@codemirror/state'; +import { CompletionContext } from '@codemirror/autocomplete'; +import { EditorState, Extension } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; -import { DataLinkBuiltInVars, VariableOrigin, VariableSuggestion } from '@grafana/data'; +import { createTheme, DataLinkBuiltInVars, VariableOrigin, VariableSuggestion } from '@grafana/data'; -import { dataLinkAutocompletion } from './codemirrorUtils'; +import { + createDataLinkAutocompletion, + createDataLinkHighlighter, + createDataLinkTheme, + 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, - }, - ]; +// Mock DOM elements required by CodeMirror +beforeAll(() => { + Range.prototype.getClientRects = jest.fn(() => ({ + item: () => null, + length: 0, + [Symbol.iterator]: jest.fn(), + })); + Range.prototype.getBoundingClientRect = jest.fn(() => ({ + x: 0, + y: 0, + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + toJSON: () => {}, + })); +}); - // Helper function to create a mock CompletionContext - function createMockContext(text: string, pos: number, explicit = false): CompletionContext { - const state = EditorState.create({ doc: text }); +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.Template, + }, +]; - return { - state, - pos, - explicit, - matchBefore: (regex: RegExp) => { - const textBefore = text.slice(0, pos); - const match = textBefore.match(regex); - if (match) { +describe('codemirrorUtils', () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + }); + + /** + * Helper to create editor with extensions + */ + function createEditor(text: string, extensions: Extension | Extension[]) { + const state = EditorState.create({ + doc: text, + extensions, + }); + return new EditorView({ state, parent: container }); + } + + describe('createDataLinkTheme', () => { + it('creates theme for light mode', () => { + const theme = createTheme({ colors: { mode: 'light' } }); + const themeExtension = createDataLinkTheme(theme); + + expect(themeExtension).toBeDefined(); + expect(Array.isArray(themeExtension)).toBe(true); + }); + + it('creates theme for dark mode', () => { + const theme = createTheme({ colors: { mode: 'dark' } }); + const themeExtension = createDataLinkTheme(theme); + + expect(themeExtension).toBeDefined(); + expect(Array.isArray(themeExtension)).toBe(true); + }); + + it('applies theme to editor', () => { + const theme = createTheme({ colors: { mode: 'light' } }); + const themeExtension = createDataLinkTheme(theme); + const view = createEditor('${test}', themeExtension); + + expect(view.dom).toBeInstanceOf(HTMLElement); + view.destroy(); + }); + + it('applies theme with variable highlighting', () => { + const theme = createTheme({ colors: { mode: 'dark' } }); + const themeExtension = createDataLinkTheme(theme); + const highlighter = createDataLinkHighlighter(); + const view = createEditor('${variable}', [themeExtension, highlighter]); + + expect(view.dom).toBeInstanceOf(HTMLElement); + const content = view.dom.textContent; + expect(content).toBe('${variable}'); + view.destroy(); + }); + }); + + describe('createDataLinkHighlighter', () => { + it('creates highlighter extension', () => { + const highlighter = createDataLinkHighlighter(); + + expect(highlighter).toBeDefined(); + }); + + it('highlights single variable', () => { + const highlighter = createDataLinkHighlighter(); + const view = createEditor('${variable}', [highlighter]); + + const content = view.dom.textContent; + expect(content).toBe('${variable}'); + view.destroy(); + }); + + it('highlights multiple variables', () => { + const highlighter = createDataLinkHighlighter(); + const view = createEditor('${var1} and ${var2}', [highlighter]); + + const content = view.dom.textContent; + expect(content).toBe('${var1} and ${var2}'); + view.destroy(); + }); + + it('highlights variables in URLs', () => { + const highlighter = createDataLinkHighlighter(); + const view = createEditor('https://example.com?id=${id}&name=${name}', [highlighter]); + + const content = view.dom.textContent; + expect(content).toBe('https://example.com?id=${id}&name=${name}'); + view.destroy(); + }); + + it('does not highlight incomplete variables', () => { + const highlighter = createDataLinkHighlighter(); + const view = createEditor('${incomplete', [highlighter]); + + const content = view.dom.textContent; + expect(content).toBe('${incomplete'); + view.destroy(); + }); + + it('highlights variables with dots', () => { + const highlighter = createDataLinkHighlighter(); + const view = createEditor('${__series.name}', [highlighter]); + + const content = view.dom.textContent; + expect(content).toBe('${__series.name}'); + view.destroy(); + }); + + it('highlights variables with underscores', () => { + const highlighter = createDataLinkHighlighter(); + const view = createEditor('${__field_name}', [highlighter]); + + const content = view.dom.textContent; + expect(content).toBe('${__field_name}'); + view.destroy(); + }); + + it('updates highlights when document changes', () => { + const highlighter = createDataLinkHighlighter(); + const view = createEditor('initial', [highlighter]); + + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: '${newVar}' }, + }); + + const content = view.dom.textContent; + expect(content).toBe('${newVar}'); + view.destroy(); + }); + }); + + describe('dataLinkAutocompletion', () => { + /** + * Helper to create a mock completion context + */ + function createMockContext( + text: string, + pos: number, + explicit = false + ): CompletionContext { + const state = EditorState.create({ doc: text }); + return { + state, + pos, + explicit, + matchBefore: (regex: RegExp) => { + const before = text.slice(0, pos); + const match = before.match(regex); + if (!match) { + return null; + } + const from = pos - match[0].length; return { - from: pos - match[0].length, + from, 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; + aborted: false, + addEventListener: jest.fn(), + } as unknown as CompletionContext; + } - expect(result).not.toBeNull(); - expect(result.options).toHaveLength(1); - expect(result.options[0].label).toBe('test'); + describe('explicit completion', () => { + it('shows all suggestions on explicit trigger', () => { + const autocomplete = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('', 0, true); + + const result = autocomplete(context); + + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(4); + expect(result?.from).toBe(0); + }); + + it('formats series variable correctly', () => { + const autocomplete = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('', 0, true); + + const result = autocomplete(context); + + const seriesOption = result?.options.find((opt) => opt.label === '__series.name'); + expect(seriesOption).toBeDefined(); + expect(seriesOption?.apply).toBe('${__series.name}'); + }); + + it('formats field variable correctly', () => { + const autocomplete = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('', 0, true); + + const result = autocomplete(context); + + const fieldOption = result?.options.find((opt) => opt.label === '__field.name'); + expect(fieldOption).toBeDefined(); + expect(fieldOption?.apply).toBe('${__field.name}'); + }); + + it('formats template variable with queryparam', () => { + const autocomplete = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('', 0, true); + + const result = autocomplete(context); + + const templateOption = result?.options.find((opt) => opt.label === 'myVar'); + expect(templateOption).toBeDefined(); + expect(templateOption?.apply).toBe('${myVar:queryparam}'); + }); + + it('formats includeVars without queryparam', () => { + const autocomplete = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('', 0, true); + + const result = autocomplete(context); + + const includeVarsOption = result?.options.find((opt) => opt.label === '__all_variables'); + expect(includeVarsOption).toBeDefined(); + expect(includeVarsOption?.apply).toBe('${__all_variables}'); + }); + + it('returns null when no suggestions available', () => { + const autocomplete = dataLinkAutocompletion([]); + const context = createMockContext('', 0, true); + + const result = autocomplete(context); + + expect(result).toBeNull(); + }); }); - it('should include all metadata fields in completion options', () => { - const autocompletion = dataLinkAutocompletion(mockSuggestions); - const context = createMockContext('$', 1); - const result = autocompletion(context) as CompletionResult; + describe('trigger on $ character', () => { + it('shows completions after typing $', () => { + const autocomplete = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('$', 1, false); - 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'); + const result = autocomplete(context); + + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(4); + }); + + it('shows completions after typing ${', () => { + const autocomplete = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('${', 2, false); + + const result = autocomplete(context); + + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(4); + }); + + it('shows completions while typing variable name', () => { + const autocomplete = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('${ser', 5, false); + + const result = autocomplete(context); + + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(4); + }); + + it('does not show completions without trigger', () => { + const autocomplete = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('test', 4, false); + + const result = autocomplete(context); + + expect(result).toBeNull(); + }); + }); + + describe('trigger on = character', () => { + it('shows completions after typing =', () => { + const autocomplete = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('url?param=', 10, false); + + const result = autocomplete(context); + + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(4); + }); + + it('shows completions after typing =${', () => { + const autocomplete = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('url?param=${', 12, false); + + const result = autocomplete(context); + + expect(result).not.toBeNull(); + expect(result?.options).toHaveLength(4); + }); + }); + + describe('option metadata', () => { + it('includes label for all options', () => { + const autocomplete = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('$', 1, false); + + const result = autocomplete(context); + + result?.options.forEach((option) => { + expect(option.label).toBeDefined(); + expect(typeof option.label).toBe('string'); + }); + }); + + it('includes detail (origin) for all options', () => { + const autocomplete = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('$', 1, false); + + const result = autocomplete(context); + + result?.options.forEach((option) => { + expect(option.detail).toBeDefined(); + }); + }); + + it('includes documentation info for all options', () => { + const autocomplete = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('$', 1, false); + + const result = autocomplete(context); + + result?.options.forEach((option) => { + expect(option.info).toBeDefined(); + expect(typeof option.info).toBe('string'); + }); + }); + + it('sets type to variable for all options', () => { + const autocomplete = dataLinkAutocompletion(mockSuggestions); + const context = createMockContext('$', 1, false); + + const result = autocomplete(context); + + result?.options.forEach((option) => { + 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; + describe('createDataLinkAutocompletion', () => { + it('creates autocompletion extension', () => { + const extension = createDataLinkAutocompletion(mockSuggestions); - expect(result).not.toBeNull(); - expect(result.from).toBe(10); - expect(result.options).toHaveLength(4); + expect(extension).toBeDefined(); }); - 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; + it('applies autocompletion to editor', () => { + const extension = createDataLinkAutocompletion(mockSuggestions); + const view = createEditor('', [extension]); - expect(result).not.toBeNull(); - expect(result.from).toBe(0); - expect(result.options).toHaveLength(4); + expect(view.dom).toBeInstanceOf(HTMLElement); + view.destroy(); }); - 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); + it('works with empty suggestions', () => { + const extension = createDataLinkAutocompletion([]); + const view = createEditor('', [extension]); - expect(result).toBeNull(); + expect(view.dom).toBeInstanceOf(HTMLElement); + view.destroy(); + }); + + it('integrates with theme and highlighter', () => { + const theme = createTheme({ colors: { mode: 'light' } }); + const themeExtension = createDataLinkTheme(theme); + const highlighter = createDataLinkHighlighter(); + const autocompletion = createDataLinkAutocompletion(mockSuggestions); + + const view = createEditor('${test}', [themeExtension, highlighter, autocompletion]); + + expect(view.dom).toBeInstanceOf(HTMLElement); + const content = view.dom.textContent; + expect(content).toBe('${test}'); + view.destroy(); }); }); - 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; + describe('integration tests', () => { + it('combines all utilities together', () => { + const theme = createTheme({ colors: { mode: 'dark' } }); + const themeExtension = createDataLinkTheme(theme); + const highlighter = createDataLinkHighlighter(); + const autocompletion = createDataLinkAutocompletion(mockSuggestions); - expect(result).not.toBeNull(); - expect(result.from).toBe(9); + const view = createEditor( + 'https://example.com?id=${id}&name=${name}', + [themeExtension, highlighter, autocompletion] + ); + + expect(view.dom).toBeInstanceOf(HTMLElement); + const content = view.dom.textContent; + expect(content).toBe('https://example.com?id=${id}&name=${name}'); + view.destroy(); }); - it('should handle completion between variables', () => { - const autocompletion = dataLinkAutocompletion(mockSuggestions); - const context = createMockContext('${var1}$${var2}', 8); - const result = autocompletion(context) as CompletionResult; + it('handles dynamic content updates', () => { + const theme = createTheme({ colors: { mode: 'light' } }); + const themeExtension = createDataLinkTheme(theme); + const highlighter = createDataLinkHighlighter(); - expect(result).not.toBeNull(); - expect(result.from).toBe(8); - }); + const view = createEditor('initial', [themeExtension, highlighter]); - 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; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: '${variable} updated' }, + }); - expect(result).not.toBeNull(); - expect(result.from).toBe(14); + const content = view.dom.textContent; + expect(content).toBe('${variable} updated'); + view.destroy(); }); }); });