Compare commits
5 Commits
ash/react-
...
ismail/imp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3448f34f9 | ||
|
|
a231cf3318 | ||
|
|
c923b58ef7 | ||
|
|
d84652d8aa | ||
|
|
317bc94634 |
@@ -63,6 +63,11 @@
|
|||||||
"not IE 11"
|
"not IE 11"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"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/css": "11.13.5",
|
||||||
"@emotion/react": "11.14.0",
|
"@emotion/react": "11.14.0",
|
||||||
"@emotion/serialize": "1.3.3",
|
"@emotion/serialize": "1.3.3",
|
||||||
@@ -73,6 +78,7 @@
|
|||||||
"@grafana/i18n": "12.4.0-pre",
|
"@grafana/i18n": "12.4.0-pre",
|
||||||
"@grafana/schema": "12.4.0-pre",
|
"@grafana/schema": "12.4.0-pre",
|
||||||
"@hello-pangea/dnd": "18.0.1",
|
"@hello-pangea/dnd": "18.0.1",
|
||||||
|
"@lezer/highlight": "^1.2.0",
|
||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
"@popperjs/core": "2.11.8",
|
"@popperjs/core": "2.11.8",
|
||||||
"@rc-component/drawer": "1.3.0",
|
"@rc-component/drawer": "1.3.0",
|
||||||
|
|||||||
@@ -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(<CodeMirrorEditor value="Hello World" onChange={onChange} />);
|
||||||
|
|
||||||
|
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(<CodeMirrorEditor value="" onChange={onChange} placeholder={placeholder} />);
|
||||||
|
|
||||||
|
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(<CodeMirrorEditor value="" onChange={onChange} ariaLabel={ariaLabel} />);
|
||||||
|
|
||||||
|
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(<CodeMirrorEditor value="" onChange={onChange} />);
|
||||||
|
|
||||||
|
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 <CodeMirrorEditor value={value} onChange={onChange} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rerender } = render(<TestWrapper initialValue="first" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender(<TestWrapper initialValue="second" />);
|
||||||
|
|
||||||
|
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(<CodeMirrorEditor value="${test}" onChange={onChange} highlightConfig={highlightConfig} />);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<CodeMirrorEditor
|
||||||
|
value="test keyword"
|
||||||
|
onChange={onChange}
|
||||||
|
highlighterFactory={customHighlighter}
|
||||||
|
highlightConfig={highlightConfig}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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<SyntaxHighlightConfig>({
|
||||||
|
pattern,
|
||||||
|
className: 'highlight',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setConfig({ pattern, className: 'highlight' });
|
||||||
|
}, [pattern]);
|
||||||
|
|
||||||
|
return <CodeMirrorEditor value="${var}" onChange={onChange} highlightConfig={config} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rerender } = render(<TestWrapper pattern={/\$\{[^}]+\}/g} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender(<TestWrapper pattern={/\d+/g} />);
|
||||||
|
|
||||||
|
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(<CodeMirrorEditor value="plain text" onChange={onChange} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const editor = screen.getByRole('textbox');
|
||||||
|
expect(editor).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('theme functionality', () => {
|
||||||
|
it('renders with default theme', async () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
|
||||||
|
render(<CodeMirrorEditor value="test" onChange={onChange} />);
|
||||||
|
|
||||||
|
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(<CodeMirrorEditor value="test" onChange={onChange} themeFactory={customTheme} />);
|
||||||
|
|
||||||
|
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 <CodeMirrorEditor value="test" onChange={onChange} themeFactory={themeFactory} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rerender } = render(<TestWrapper themeFactory={theme1} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender(<TestWrapper themeFactory={theme2} />);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<CodeMirrorEditor
|
||||||
|
value="${variable} test"
|
||||||
|
onChange={onChange}
|
||||||
|
themeFactory={customTheme}
|
||||||
|
highlightConfig={highlightConfig}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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<SyntaxHighlightConfig>({
|
||||||
|
pattern,
|
||||||
|
className: 'highlight',
|
||||||
|
});
|
||||||
|
const [themeFactory, setThemeFactory] = React.useState<ThemeFactory>(
|
||||||
|
() => (theme: GrafanaTheme2) => createGenericTheme(theme)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setConfig({ pattern, className: 'highlight' });
|
||||||
|
setThemeFactory(() => (theme: GrafanaTheme2) => {
|
||||||
|
const customTheme = createTheme({ colors: { mode } });
|
||||||
|
return createGenericTheme(customTheme);
|
||||||
|
});
|
||||||
|
}, [pattern, mode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeMirrorEditor
|
||||||
|
value="${var} 123"
|
||||||
|
onChange={onChange}
|
||||||
|
themeFactory={themeFactory}
|
||||||
|
highlightConfig={config}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rerender } = render(<TestWrapper pattern={/\$\{[^}]+\}/g} mode="light" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender(<TestWrapper pattern={/\d+/g} mode="dark" />);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<CodeMirrorEditor
|
||||||
|
value="Line 1\nLine 2\nLine 3"
|
||||||
|
onChange={onChange}
|
||||||
|
showLineNumbers={true}
|
||||||
|
highlightConfig={highlightConfig}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<CodeMirrorEditor
|
||||||
|
value="test"
|
||||||
|
onChange={onChange}
|
||||||
|
extensions={customExtension}
|
||||||
|
highlightConfig={highlightConfig}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(<CodeMirrorEditor value="test" onChange={onChange} className={customClassName} />);
|
||||||
|
|
||||||
|
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(<CodeMirrorEditor value="test" onChange={onChange} useInputStyles={true} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const editor = screen.getByRole('textbox');
|
||||||
|
expect(editor).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with input styles disabled', async () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
|
||||||
|
render(<CodeMirrorEditor value="test" onChange={onChange} useInputStyles={false} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const editor = screen.getByRole('textbox');
|
||||||
|
expect(editor).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { 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,
|
||||||
|
ViewUpdate,
|
||||||
|
} from '@codemirror/view';
|
||||||
|
import { css, cx } from '@emotion/css';
|
||||||
|
import { memo, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
|
||||||
|
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
|
||||||
|
import { getInputStyles } from '../Input/Input';
|
||||||
|
|
||||||
|
import { createGenericHighlighter } from './highlight';
|
||||||
|
import { createGenericTheme } from './styles';
|
||||||
|
import { CodeMirrorEditorProps } from './types';
|
||||||
|
|
||||||
|
export const CodeMirrorEditor = memo((props: CodeMirrorEditorProps) => {
|
||||||
|
const {
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = '',
|
||||||
|
themeFactory,
|
||||||
|
highlighterFactory,
|
||||||
|
highlightConfig,
|
||||||
|
autocompletion: autocompletionExtension,
|
||||||
|
extensions = [],
|
||||||
|
showLineNumbers = false,
|
||||||
|
lineWrapping = true,
|
||||||
|
ariaLabel,
|
||||||
|
className,
|
||||||
|
useInputStyles = true,
|
||||||
|
closeBrackets: enableCloseBrackets = true,
|
||||||
|
} = props;
|
||||||
|
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const editorViewRef = useRef<EditorView | null>(null);
|
||||||
|
const styles = useStyles2((theme) => getStyles(theme, useInputStyles));
|
||||||
|
const theme = useTheme2();
|
||||||
|
const themeCompartment = useRef(new Compartment());
|
||||||
|
const autocompletionCompartment = useRef(new Compartment());
|
||||||
|
|
||||||
|
const customKeymap = keymap.of([...closeBracketsKeymap, ...completionKeymap, ...historyKeymap, ...defaultKeymap]);
|
||||||
|
|
||||||
|
// Build theme extensions
|
||||||
|
const getThemeExtensions = () => {
|
||||||
|
const themeExt = themeFactory ? themeFactory(theme) : createGenericTheme(theme);
|
||||||
|
const highlighterExt = highlighterFactory
|
||||||
|
? highlighterFactory(highlightConfig)
|
||||||
|
: highlightConfig
|
||||||
|
? createGenericHighlighter(highlightConfig)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return [themeExt, highlighterExt];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize CodeMirror editor
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editorContainerRef.current || editorViewRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseExtensions = [
|
||||||
|
highlightActiveLine(),
|
||||||
|
highlightSpecialChars(),
|
||||||
|
history(),
|
||||||
|
foldGutter(),
|
||||||
|
drawSelection(),
|
||||||
|
dropCursor(),
|
||||||
|
EditorState.allowMultipleSelections.of(true),
|
||||||
|
indentOnInput(),
|
||||||
|
bracketMatching(),
|
||||||
|
rectangularSelection(),
|
||||||
|
customKeymap,
|
||||||
|
placeholderExtension(placeholder),
|
||||||
|
EditorView.updateListener.of((update: ViewUpdate) => {
|
||||||
|
if (update.docChanged) {
|
||||||
|
const newValue = update.state.doc.toString();
|
||||||
|
onChange(newValue);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
themeCompartment.current.of(getThemeExtensions()),
|
||||||
|
EditorState.phrases.of({
|
||||||
|
next: 'Next',
|
||||||
|
previous: 'Previous',
|
||||||
|
Completions: 'Completions',
|
||||||
|
}),
|
||||||
|
EditorView.editorAttributes.of({ 'aria-label': ariaLabel || placeholder }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Conditionally add closeBrackets extension
|
||||||
|
if (enableCloseBrackets) {
|
||||||
|
baseExtensions.push(closeBrackets());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add optional extensions
|
||||||
|
if (showLineNumbers) {
|
||||||
|
baseExtensions.push(lineNumbers());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineWrapping) {
|
||||||
|
baseExtensions.push(EditorView.lineWrapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autocompletionExtension) {
|
||||||
|
baseExtensions.push(autocompletionCompartment.current.of(autocompletionExtension));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom extensions
|
||||||
|
if (extensions.length > 0) {
|
||||||
|
baseExtensions.push(...extensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startState = EditorState.create({
|
||||||
|
doc: value,
|
||||||
|
extensions: baseExtensions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const view = new EditorView({
|
||||||
|
state: startState,
|
||||||
|
parent: editorContainerRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
editorViewRef.current = view;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
view.destroy();
|
||||||
|
editorViewRef.current = null;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update editor value when prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (editorViewRef.current) {
|
||||||
|
const currentValue = editorViewRef.current.state.doc.toString();
|
||||||
|
if (currentValue !== value) {
|
||||||
|
editorViewRef.current.dispatch({
|
||||||
|
changes: { from: 0, to: currentValue.length, insert: value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// Update theme when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (editorViewRef.current) {
|
||||||
|
editorViewRef.current.dispatch({
|
||||||
|
effects: themeCompartment.current.reconfigure(getThemeExtensions()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [theme, themeFactory, highlighterFactory, highlightConfig]);
|
||||||
|
|
||||||
|
// Update autocompletion when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (editorViewRef.current && autocompletionExtension) {
|
||||||
|
editorViewRef.current.dispatch({
|
||||||
|
effects: autocompletionCompartment.current.reconfigure(autocompletionExtension),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [autocompletionExtension]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx(styles.container, className)}>
|
||||||
|
<div className={styles.input} ref={editorContainerRef} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
CodeMirrorEditor.displayName = 'CodeMirrorEditor';
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2, useInputStyles: boolean) => {
|
||||||
|
const baseInputStyles = useInputStyles ? getInputStyles({ theme, invalid: false }).input : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
container: css({
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
}),
|
||||||
|
input: css(baseInputStyles),
|
||||||
|
};
|
||||||
|
};
|
||||||
246
packages/grafana-ui/src/components/CodeMirror/README.md
Normal file
246
packages/grafana-ui/src/components/CodeMirror/README.md
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
# CodeMirror Editor Component
|
||||||
|
|
||||||
|
A reusable CodeMirror editor component for Grafana that provides a flexible and themeable code editing experience.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `CodeMirrorEditor` component is a generic, theme-aware editor built on CodeMirror 6. Use it anywhere you need code editing functionality with syntax highlighting, autocompletion, and Grafana theme integration.
|
||||||
|
|
||||||
|
## Basic usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CodeMirrorEditor } from '@grafana/ui';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeMirrorEditor
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
placeholder="Enter your code here"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced usage
|
||||||
|
|
||||||
|
### Custom syntax highlighting
|
||||||
|
|
||||||
|
Create a custom highlighter for your specific syntax:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CodeMirrorEditor, SyntaxHighlightConfig } from '@grafana/ui';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
|
||||||
|
const highlightConfig: SyntaxHighlightConfig = {
|
||||||
|
pattern: /\b(SELECT|FROM|WHERE)\b/gi, // Highlight SQL keywords
|
||||||
|
className: 'cm-keyword',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeMirrorEditor
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
highlightConfig={highlightConfig}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom theme
|
||||||
|
|
||||||
|
Extend the default theme with your own styling:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CodeMirrorEditor, ThemeFactory } from '@grafana/ui';
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import { createGenericTheme } from '@grafana/ui';
|
||||||
|
|
||||||
|
const myCustomTheme: ThemeFactory = (theme) => {
|
||||||
|
const baseTheme = createGenericTheme(theme);
|
||||||
|
|
||||||
|
const customStyles = EditorView.theme({
|
||||||
|
'.cm-keyword': {
|
||||||
|
color: theme.colors.primary.text,
|
||||||
|
fontWeight: theme.typography.fontWeightBold,
|
||||||
|
},
|
||||||
|
'.cm-string': {
|
||||||
|
color: theme.colors.success.text,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return [baseTheme, customStyles];
|
||||||
|
};
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
return (
|
||||||
|
<CodeMirrorEditor
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
themeFactory={myCustomTheme}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom autocompletion
|
||||||
|
|
||||||
|
Add autocompletion for your specific use case:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CodeMirrorEditor } from '@grafana/ui';
|
||||||
|
import { autocompletion, CompletionContext } from '@codemirror/autocomplete';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
|
||||||
|
const autocompletionExtension = useMemo(() => {
|
||||||
|
return autocompletion({
|
||||||
|
override: [(context: CompletionContext) => {
|
||||||
|
const word = context.matchBefore(/\w*/);
|
||||||
|
if (!word || word.from === word.to) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: word.from,
|
||||||
|
options: [
|
||||||
|
{ label: 'hello', type: 'keyword' },
|
||||||
|
{ label: 'world', type: 'keyword' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}],
|
||||||
|
activateOnTyping: true,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeMirrorEditor
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
autocompletion={autocompletionExtension}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Additional extensions
|
||||||
|
|
||||||
|
Add custom CodeMirror extensions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CodeMirrorEditor } from '@grafana/ui';
|
||||||
|
import { javascript } from '@codemirror/lang-javascript';
|
||||||
|
import { linter } from '@codemirror/lint';
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const extensions = useMemo(() => [
|
||||||
|
javascript(),
|
||||||
|
linter(/* your linting logic */),
|
||||||
|
], []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeMirrorEditor
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
extensions={extensions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `value` | `string` | required | The current value of the editor |
|
||||||
|
| `onChange` | `(value: string, callback?: () => void) => void` | required | Callback when the editor value changes |
|
||||||
|
| `placeholder` | `string` | `''` | Placeholder text when editor is empty |
|
||||||
|
| `themeFactory` | `ThemeFactory` | `createGenericTheme` | Custom theme factory function |
|
||||||
|
| `highlighterFactory` | `HighlighterFactory` | `createGenericHighlighter` | Custom syntax highlighter factory |
|
||||||
|
| `highlightConfig` | `SyntaxHighlightConfig` | `undefined` | Configuration for syntax highlighting |
|
||||||
|
| `autocompletion` | `Extension` | `undefined` | Custom autocompletion extension |
|
||||||
|
| `extensions` | `Extension[]` | `[]` | Additional CodeMirror extensions |
|
||||||
|
| `showLineNumbers` | `boolean` | `false` | Whether to show line numbers |
|
||||||
|
| `lineWrapping` | `boolean` | `true` | Whether to enable line wrapping |
|
||||||
|
| `ariaLabel` | `string` | `placeholder` | Aria label for accessibility |
|
||||||
|
| `className` | `string` | `undefined` | Custom CSS class for the container |
|
||||||
|
| `useInputStyles` | `boolean` | `true` | Whether to apply Grafana input styles |
|
||||||
|
|
||||||
|
## Example: DataLink editor
|
||||||
|
|
||||||
|
Here's how the DataLink component uses the CodeMirror editor:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CodeMirrorEditor } from '@grafana/ui';
|
||||||
|
import { createDataLinkAutocompletion, createDataLinkHighlighter, createDataLinkTheme } from './codemirrorUtils';
|
||||||
|
|
||||||
|
export const DataLinkInput = memo(({ value, onChange, suggestions, placeholder }) => {
|
||||||
|
const autocompletionExtension = useMemo(
|
||||||
|
() => createDataLinkAutocompletion(suggestions),
|
||||||
|
[suggestions]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CodeMirrorEditor
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
themeFactory={createDataLinkTheme}
|
||||||
|
highlighterFactory={createDataLinkHighlighter}
|
||||||
|
autocompletion={autocompletionExtension}
|
||||||
|
ariaLabel={placeholder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Utilities
|
||||||
|
|
||||||
|
### `createGenericTheme(theme: GrafanaTheme2): Extension`
|
||||||
|
|
||||||
|
Creates a generic CodeMirror theme based on Grafana's theme.
|
||||||
|
|
||||||
|
### `createGenericHighlighter(theme: GrafanaTheme2, config: SyntaxHighlightConfig): Extension`
|
||||||
|
|
||||||
|
Creates a generic syntax highlighter based on a regex pattern and CSS class name.
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SyntaxHighlightConfig {
|
||||||
|
pattern: RegExp;
|
||||||
|
className: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThemeFactory = (theme: GrafanaTheme2) => Extension;
|
||||||
|
type HighlighterFactory = (theme: GrafanaTheme2, config?: SyntaxHighlightConfig) => Extension;
|
||||||
|
type AutocompletionFactory<T = unknown> = (data: T) => Extension;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Theme-aware**: Automatically adapts to Grafana's light and dark themes
|
||||||
|
- **Syntax highlighting**: Configurable pattern-based syntax highlighting
|
||||||
|
- **Autocompletion**: Customizable autocompletion with keyboard shortcuts
|
||||||
|
- **Accessibility**: Built-in ARIA support
|
||||||
|
- **Line numbers**: Optional line number display
|
||||||
|
- **Line wrapping**: Configurable line wrapping
|
||||||
|
- **Modal-friendly**: Tooltips render at body level to prevent clipping
|
||||||
|
- **Extensible**: Support for custom CodeMirror extensions
|
||||||
|
|
||||||
|
## Best practices
|
||||||
|
|
||||||
|
1. **Memoize extensions**: Use `useMemo` to create autocompletion and other extensions to avoid recreating them on every render.
|
||||||
|
|
||||||
|
2. **Custom themes**: Extend the generic theme rather than replacing it to maintain consistency with Grafana's design system.
|
||||||
|
|
||||||
|
3. **Pattern efficiency**: Use efficient regex patterns for syntax highlighting to avoid performance issues with large documents.
|
||||||
|
|
||||||
|
4. **Accessibility**: Always provide meaningful `ariaLabel` or `placeholder` text for screen readers.
|
||||||
|
|
||||||
|
5. **Type safety**: Use the provided TypeScript types for better type safety and IDE support.
|
||||||
246
packages/grafana-ui/src/components/CodeMirror/highlight.test.ts
Normal file
246
packages/grafana-ui/src/components/CodeMirror/highlight.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
54
packages/grafana-ui/src/components/CodeMirror/highlight.ts
Normal file
54
packages/grafana-ui/src/components/CodeMirror/highlight.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Extension } from '@codemirror/state';
|
||||||
|
import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
|
||||||
|
|
||||||
|
import { SyntaxHighlightConfig } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a generic syntax highlighter based on a pattern and class name
|
||||||
|
*/
|
||||||
|
export function createGenericHighlighter(config: SyntaxHighlightConfig): Extension {
|
||||||
|
const { pattern, className } = config;
|
||||||
|
|
||||||
|
const decoration = Decoration.mark({
|
||||||
|
class: className,
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
|
||||||
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
|
decorations.push({
|
||||||
|
from: match.index,
|
||||||
|
to: match.index + match[0].length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Decoration.set(decorations.map((range) => decoration.range(range.from, range.to)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decorations: (v) => v.decorations,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return viewPlugin;
|
||||||
|
}
|
||||||
189
packages/grafana-ui/src/components/CodeMirror/styles.test.ts
Normal file
189
packages/grafana-ui/src/components/CodeMirror/styles.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
92
packages/grafana-ui/src/components/CodeMirror/styles.ts
Normal file
92
packages/grafana-ui/src/components/CodeMirror/styles.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { Extension } from '@codemirror/state';
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a generic CodeMirror theme based on Grafana's theme
|
||||||
|
*/
|
||||||
|
export function createGenericTheme(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-activeLine': {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
'.cm-tooltip.cm-tooltip-autocomplete': {
|
||||||
|
backgroundColor: theme.colors.background.primary,
|
||||||
|
border: `1px solid ${theme.colors.border.weak}`,
|
||||||
|
boxShadow: theme.shadows.z3,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
},
|
||||||
|
'.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,
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
'.cm-tooltip.cm-tooltip-autocomplete > ul > li:hover': {
|
||||||
|
backgroundColor: theme.colors.background.secondary,
|
||||||
|
},
|
||||||
|
'.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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
107
packages/grafana-ui/src/components/CodeMirror/types.ts
Normal file
107
packages/grafana-ui/src/components/CodeMirror/types.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { Extension } from '@codemirror/state';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration options for syntax highlighting
|
||||||
|
*/
|
||||||
|
export interface SyntaxHighlightConfig {
|
||||||
|
/**
|
||||||
|
* Pattern to match for highlighting
|
||||||
|
*/
|
||||||
|
pattern: RegExp;
|
||||||
|
/**
|
||||||
|
* CSS class to apply to matched text
|
||||||
|
*/
|
||||||
|
className: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to create a theme extension
|
||||||
|
*/
|
||||||
|
export type ThemeFactory = (theme: GrafanaTheme2) => Extension;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to create a syntax highlighter extension
|
||||||
|
*/
|
||||||
|
export type HighlighterFactory = (config?: SyntaxHighlightConfig) => Extension;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to create an autocompletion extension
|
||||||
|
*/
|
||||||
|
export type AutocompletionFactory<T = unknown> = (data: T) => Extension;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the CodeMirrorEditor component
|
||||||
|
*/
|
||||||
|
export interface CodeMirrorEditorProps {
|
||||||
|
/**
|
||||||
|
* The current value of the editor
|
||||||
|
*/
|
||||||
|
value: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when the editor value changes
|
||||||
|
*/
|
||||||
|
onChange: (value: string, callback?: () => void) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder text to display when editor is empty
|
||||||
|
*/
|
||||||
|
placeholder?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom theme factory function
|
||||||
|
*/
|
||||||
|
themeFactory?: ThemeFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom syntax highlighter factory function
|
||||||
|
*/
|
||||||
|
highlighterFactory?: HighlighterFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for syntax highlighting
|
||||||
|
*/
|
||||||
|
highlightConfig?: SyntaxHighlightConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom autocompletion extension
|
||||||
|
*/
|
||||||
|
autocompletion?: Extension;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional CodeMirror extensions to apply
|
||||||
|
*/
|
||||||
|
extensions?: Extension[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show line numbers (default: false)
|
||||||
|
*/
|
||||||
|
showLineNumbers?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to enable line wrapping (default: true)
|
||||||
|
*/
|
||||||
|
lineWrapping?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aria label for accessibility
|
||||||
|
*/
|
||||||
|
ariaLabel?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom CSS class for the container
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to apply input styles (default: true)
|
||||||
|
*/
|
||||||
|
useInputStyles?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to enable automatic closing of brackets and braces (default: true)
|
||||||
|
*/
|
||||||
|
closeBrackets?: boolean;
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ export const DataLinkEditor = memo(
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label={t('grafana-ui.data-link-editor.url-label', 'URL')}>
|
<Field label={t('grafana-ui.data-link-editor.url-label', 'URL')} className={styles.urlField}>
|
||||||
<DataLinkInput value={value.url} onChange={onUrlChange} suggestions={suggestions} />
|
<DataLinkInput value={value.url} onChange={onUrlChange} suggestions={suggestions} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -88,6 +88,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
listItem: css({
|
listItem: css({
|
||||||
marginBottom: theme.spacing(),
|
marginBottom: theme.spacing(),
|
||||||
}),
|
}),
|
||||||
|
urlField: css({
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: theme.zIndex.typeahead,
|
||||||
|
}),
|
||||||
infoText: css({
|
infoText: css({
|
||||||
paddingBottom: theme.spacing(2),
|
paddingBottom: theme.spacing(2),
|
||||||
marginLeft: '66px',
|
marginLeft: '66px',
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
// Mock getClientRects for CodeMirror in JSDOM
|
||||||
|
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: () => {},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
<DataLinkInput value="https://grafana.com" onChange={onChange} suggestions={mockSuggestions} />
|
||||||
|
);
|
||||||
|
|
||||||
|
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(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} placeholder={placeholder} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const editor = screen.getByRole('textbox');
|
||||||
|
expect(editor).toHaveAttribute('aria-placeholder', placeholder);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onChange when value changes', async () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
|
||||||
|
|
||||||
|
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(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = screen.getByRole('textbox');
|
||||||
|
await user.click(editor);
|
||||||
|
await user.keyboard('$');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows suggestions menu when = is typed', async () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = screen.getByRole('textbox');
|
||||||
|
await user.click(editor);
|
||||||
|
await user.keyboard('=');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes suggestions on Escape key', async () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = screen.getByRole('textbox');
|
||||||
|
await user.click(editor);
|
||||||
|
await user.keyboard('$');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates suggestions with arrow keys', async () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = screen.getByRole('textbox');
|
||||||
|
await user.click(editor);
|
||||||
|
await user.keyboard('$');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate with arrow keys
|
||||||
|
await user.keyboard('{ArrowDown}');
|
||||||
|
await user.keyboard('{ArrowUp}');
|
||||||
|
|
||||||
|
// Menu should still be visible
|
||||||
|
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts variable on Enter key', async () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = screen.getByRole('textbox');
|
||||||
|
await user.click(editor);
|
||||||
|
await user.keyboard('$');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('listbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.keyboard('{Enter}');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('listbox')).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 <DataLinkInput value={value} onChange={onChange} suggestions={mockSuggestions} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rerender } = render(<TestWrapper initialValue="first" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender(<TestWrapper initialValue="second" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const editor = screen.getByRole('textbox');
|
||||||
|
expect(editor).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays component with default placeholder', async () => {
|
||||||
|
const onChange = jest.fn();
|
||||||
|
|
||||||
|
render(<DataLinkInput value="" onChange={onChange} suggestions={mockSuggestions} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const editor = screen.getByRole('textbox');
|
||||||
|
expect(editor).toHaveAttribute('aria-placeholder', 'http://your-grafana.com/d/000000010/annotations');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,27 +1,10 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import { memo, useMemo } from 'react';
|
||||||
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 { DataLinkBuiltInVars, GrafanaTheme2, VariableOrigin, VariableSuggestion } from '@grafana/data';
|
import { VariableSuggestion } from '@grafana/data';
|
||||||
|
|
||||||
import { SlatePrism } from '../../slate-plugins/slate-prism';
|
import { CodeMirrorEditor } from '../CodeMirror/CodeMirrorEditor';
|
||||||
import { useStyles2 } from '../../themes/ThemeContext';
|
|
||||||
import { getPositioningMiddleware } from '../../utils/floating';
|
|
||||||
import { SCHEMA, makeValue } from '../../utils/slate';
|
|
||||||
import { getInputStyles } from '../Input/Input';
|
|
||||||
import { Portal } from '../Portal/Portal';
|
|
||||||
import { ScrollContainer } from '../ScrollContainer/ScrollContainer';
|
|
||||||
|
|
||||||
import { DataLinkSuggestions } from './DataLinkSuggestions';
|
import { createDataLinkAutocompletion, createDataLinkHighlighter, createDataLinkTheme } from './codemirrorUtils';
|
||||||
import { SelectionReference } from './SelectionReference';
|
|
||||||
|
|
||||||
const modulo = (a: number, n: number) => a - n * Math.floor(a / n);
|
|
||||||
|
|
||||||
interface DataLinkInputProps {
|
interface DataLinkInputProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -30,49 +13,6 @@ interface DataLinkInputProps {
|
|||||||
placeholder?: string;
|
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(
|
export const DataLinkInput = memo(
|
||||||
({
|
({
|
||||||
value,
|
value,
|
||||||
@@ -80,175 +20,22 @@ export const DataLinkInput = memo(
|
|||||||
suggestions,
|
suggestions,
|
||||||
placeholder = 'http://your-grafana.com/d/000000010/annotations',
|
placeholder = 'http://your-grafana.com/d/000000010/annotations',
|
||||||
}: DataLinkInputProps) => {
|
}: DataLinkInputProps) => {
|
||||||
const editorRef = useRef<Editor>(null);
|
// Memoize autocompletion extension to avoid recreating on every render
|
||||||
const styles = useStyles2(getStyles);
|
const autocompletionExtension = useMemo(() => createDataLinkAutocompletion(suggestions), [suggestions]);
|
||||||
const [showingSuggestions, setShowingSuggestions] = useState(false);
|
|
||||||
const [suggestionsIndex, setSuggestionsIndex] = useState(0);
|
|
||||||
const [linkUrl, setLinkUrl] = useState<Value>(makeValue(value));
|
|
||||||
const prevLinkUrl = usePrevious<Value>(linkUrl);
|
|
||||||
const [scrollTop, setScrollTop] = useState(0);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
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<HTMLDivElement>(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();
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (event.key) {
|
|
||||||
case 'Backspace':
|
|
||||||
if (stateRef.current.linkUrl.focusText.getText().length === 1) {
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
case 'Escape':
|
|
||||||
setShowingSuggestions(false);
|
|
||||||
return setSuggestionsIndex(0);
|
|
||||||
|
|
||||||
case 'Enter':
|
|
||||||
event.preventDefault();
|
|
||||||
return onVariableSelect(stateRef.current.suggestions[stateRef.current.suggestionsIndex]);
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return precedingChar;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapperOverrides}>
|
<CodeMirrorEditor
|
||||||
<div className="slate-query-field__wrapper">
|
value={value}
|
||||||
<div id="data-link-input" className="slate-query-field">
|
onChange={onChange}
|
||||||
{showingSuggestions && (
|
placeholder={placeholder}
|
||||||
<Portal>
|
themeFactory={createDataLinkTheme}
|
||||||
<div ref={refs.setFloating} style={floatingStyles}>
|
highlighterFactory={createDataLinkHighlighter}
|
||||||
<ScrollContainer
|
autocompletion={autocompletionExtension}
|
||||||
maxHeight="300px"
|
ariaLabel={placeholder}
|
||||||
ref={scrollRef}
|
closeBrackets={false}
|
||||||
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
|
/>
|
||||||
>
|
|
||||||
<DataLinkSuggestions
|
|
||||||
activeRef={activeRef}
|
|
||||||
suggestions={stateRef.current.suggestions}
|
|
||||||
onSuggestionSelect={onVariableSelect}
|
|
||||||
onClose={() => setShowingSuggestions(false)}
|
|
||||||
activeIndex={suggestionsIndex}
|
|
||||||
/>
|
|
||||||
</ScrollContainer>
|
|
||||||
</div>
|
|
||||||
</Portal>
|
|
||||||
)}
|
|
||||||
<Editor
|
|
||||||
schema={SCHEMA}
|
|
||||||
ref={editorRef}
|
|
||||||
placeholder={placeholder}
|
|
||||||
value={stateRef.current.linkUrl}
|
|
||||||
onChange={onUrlChange}
|
|
||||||
onKeyDown={(event, _editor, next) => onKeyDown(event, next)}
|
|
||||||
plugins={plugins}
|
|
||||||
className={cx(
|
|
||||||
styles.editor,
|
|
||||||
styles.input,
|
|
||||||
css({
|
|
||||||
padding: '3px 8px',
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
DataLinkInput.displayName = 'DataLinkInput';
|
DataLinkInput.displayName = 'DataLinkInput';
|
||||||
|
|
||||||
function getElementPosition(suggestionElement: HTMLElement | null, activeIndex: number) {
|
|
||||||
return (suggestionElement?.clientHeight ?? 0) * activeIndex;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,480 @@
|
|||||||
|
import { CompletionContext } from '@codemirror/autocomplete';
|
||||||
|
import { EditorState, Extension } from '@codemirror/state';
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
|
||||||
|
import { createTheme, DataLinkBuiltInVars, VariableOrigin, VariableSuggestion } from '@grafana/data';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createDataLinkAutocompletion,
|
||||||
|
createDataLinkHighlighter,
|
||||||
|
createDataLinkTheme,
|
||||||
|
dataLinkAutocompletion,
|
||||||
|
} from './codemirrorUtils';
|
||||||
|
|
||||||
|
// 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: () => {},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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,
|
||||||
|
to: pos,
|
||||||
|
text: match[0],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
aborted: false,
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
} as unknown as CompletionContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('trigger on $ character', () => {
|
||||||
|
it('shows completions after typing $', () => {
|
||||||
|
const autocomplete = dataLinkAutocompletion(mockSuggestions);
|
||||||
|
const context = createMockContext('$', 1, 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('${', 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('createDataLinkAutocompletion', () => {
|
||||||
|
it('creates autocompletion extension', () => {
|
||||||
|
const extension = createDataLinkAutocompletion(mockSuggestions);
|
||||||
|
|
||||||
|
expect(extension).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies autocompletion to editor', () => {
|
||||||
|
const extension = createDataLinkAutocompletion(mockSuggestions);
|
||||||
|
const view = createEditor('', [extension]);
|
||||||
|
|
||||||
|
expect(view.dom).toBeInstanceOf(HTMLElement);
|
||||||
|
view.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with empty suggestions', () => {
|
||||||
|
const extension = createDataLinkAutocompletion([]);
|
||||||
|
const view = createEditor('', [extension]);
|
||||||
|
|
||||||
|
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('integration tests', () => {
|
||||||
|
it('combines all utilities together', () => {
|
||||||
|
const theme = createTheme({ colors: { mode: 'dark' } });
|
||||||
|
const themeExtension = createDataLinkTheme(theme);
|
||||||
|
const highlighter = createDataLinkHighlighter();
|
||||||
|
const autocompletion = createDataLinkAutocompletion(mockSuggestions);
|
||||||
|
|
||||||
|
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('handles dynamic content updates', () => {
|
||||||
|
const theme = createTheme({ colors: { mode: 'light' } });
|
||||||
|
const themeExtension = createDataLinkTheme(theme);
|
||||||
|
const highlighter = createDataLinkHighlighter();
|
||||||
|
|
||||||
|
const view = createEditor('initial', [themeExtension, highlighter]);
|
||||||
|
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: '${variable} updated' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = view.dom.textContent;
|
||||||
|
expect(content).toBe('${variable} updated');
|
||||||
|
view.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
145
packages/grafana-ui/src/components/DataLinks/codemirrorUtils.ts
Normal file
145
packages/grafana-ui/src/components/DataLinks/codemirrorUtils.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { autocompletion, Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||||
|
import { Extension } from '@codemirror/state';
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
|
||||||
|
import { DataLinkBuiltInVars, GrafanaTheme2, VariableOrigin, VariableSuggestion } from '@grafana/data';
|
||||||
|
|
||||||
|
import { createGenericHighlighter } from '../CodeMirror/highlight';
|
||||||
|
import { createGenericTheme } from '../CodeMirror/styles';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a CodeMirror theme for data link input with custom variable styling
|
||||||
|
* This extends the generic theme with data link-specific styles
|
||||||
|
*/
|
||||||
|
export function createDataLinkTheme(theme: GrafanaTheme2): Extension {
|
||||||
|
const genericTheme = createGenericTheme(theme);
|
||||||
|
|
||||||
|
// Add data link-specific variable styling
|
||||||
|
const dataLinkStyles = EditorView.theme({
|
||||||
|
'.cm-variable': {
|
||||||
|
color: theme.colors.success.text,
|
||||||
|
fontWeight: theme.typography.fontWeightMedium,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return [genericTheme, dataLinkStyles];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a syntax highlighter for data link variables (${...})
|
||||||
|
* Matches the pattern from the old Prism implementation: (\${\S+?})
|
||||||
|
*/
|
||||||
|
export function createDataLinkHighlighter(): Extension {
|
||||||
|
// Regular expression matching ${...} patterns (same as old implementation)
|
||||||
|
const variablePattern = /\$\{[^}]+\}/g;
|
||||||
|
|
||||||
|
return createGenericHighlighter({
|
||||||
|
pattern: variablePattern,
|
||||||
|
className: 'cm-variable',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to generate the apply text for a variable suggestion
|
||||||
|
*/
|
||||||
|
function getApplyText(suggestion: VariableSuggestion): string {
|
||||||
|
if (suggestion.origin !== VariableOrigin.Template || suggestion.value === DataLinkBuiltInVars.includeVars) {
|
||||||
|
return `\${${suggestion.value}}`;
|
||||||
|
}
|
||||||
|
return `\${${suggestion.value}:queryparam}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create a completion option from a suggestion
|
||||||
|
*/
|
||||||
|
function createCompletionOption(
|
||||||
|
suggestion: VariableSuggestion,
|
||||||
|
customApply?: (view: EditorView, completion: Completion, from: number, to: number) => void
|
||||||
|
): Completion {
|
||||||
|
const applyText = getApplyText(suggestion);
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: suggestion.label,
|
||||||
|
apply: customApply ?? applyText,
|
||||||
|
type: 'variable',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates autocomplete source function for data link variables
|
||||||
|
* Triggers on $ and = characters
|
||||||
|
*/
|
||||||
|
export function dataLinkAutocompletion(
|
||||||
|
suggestions: VariableSuggestion[]
|
||||||
|
): (context: CompletionContext) => CompletionResult | null {
|
||||||
|
return (context: CompletionContext): CompletionResult | null => {
|
||||||
|
// 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 = suggestions.map((suggestion) => createCompletionOption(suggestion));
|
||||||
|
return {
|
||||||
|
from: context.pos,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match $ or = followed by optional { and word characters
|
||||||
|
// This will match: $, ${, ${word, =, etc.
|
||||||
|
const word = context.matchBefore(/[$=]\{?[\w.]*$/);
|
||||||
|
|
||||||
|
// 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 =), use custom apply function to handle replacement
|
||||||
|
const isSingleChar = word.text.length === 1;
|
||||||
|
|
||||||
|
const options = suggestions.map((suggestion) => {
|
||||||
|
if (!isSingleChar) {
|
||||||
|
return createCompletionOption(suggestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyText = getApplyText(suggestion);
|
||||||
|
const customApply = (view: EditorView, completion: Completion, from: number, to: number) => {
|
||||||
|
// Replace from the trigger character position
|
||||||
|
const wordFrom = triggerChar === '=' ? context.pos : word.from;
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: wordFrom, to, insert: applyText },
|
||||||
|
selection: { anchor: wordFrom + applyText.length },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return createCompletionOption(suggestion, customApply);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: isSingleChar ? context.pos : word.from,
|
||||||
|
options,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a data link autocompletion extension with configured suggestions
|
||||||
|
*/
|
||||||
|
export function createDataLinkAutocompletion(suggestions: VariableSuggestion[]): Extension {
|
||||||
|
return autocompletion({
|
||||||
|
override: [dataLinkAutocompletion(suggestions)],
|
||||||
|
activateOnTyping: true,
|
||||||
|
closeOnBlur: true,
|
||||||
|
maxRenderedOptions: 100,
|
||||||
|
defaultKeymap: true,
|
||||||
|
interactionDelay: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -92,6 +92,18 @@ export {
|
|||||||
} from './components/Monaco/types';
|
} from './components/Monaco/types';
|
||||||
export { variableSuggestionToCodeEditorSuggestion } from './components/Monaco/utils';
|
export { variableSuggestionToCodeEditorSuggestion } from './components/Monaco/utils';
|
||||||
|
|
||||||
|
// CodeMirror
|
||||||
|
export { CodeMirrorEditor } from './components/CodeMirror/CodeMirrorEditor';
|
||||||
|
export { createGenericTheme } from './components/CodeMirror/styles';
|
||||||
|
export { createGenericHighlighter } from './components/CodeMirror/highlight';
|
||||||
|
export type {
|
||||||
|
CodeMirrorEditorProps,
|
||||||
|
ThemeFactory,
|
||||||
|
HighlighterFactory,
|
||||||
|
AutocompletionFactory,
|
||||||
|
SyntaxHighlightConfig,
|
||||||
|
} from './components/CodeMirror/types';
|
||||||
|
|
||||||
// TODO: namespace
|
// TODO: namespace
|
||||||
export { Modal, type Props as ModalProps } from './components/Modal/Modal';
|
export { Modal, type Props as ModalProps } from './components/Modal/Modal';
|
||||||
export { ModalHeader } from './components/Modal/ModalHeader';
|
export { ModalHeader } from './components/Modal/ModalHeader';
|
||||||
|
|||||||
111
yarn.lock
111
yarn.lock
@@ -1572,6 +1572,65 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@colors/colors@npm:1.5.0":
|
||||||
version: 1.5.0
|
version: 1.5.0
|
||||||
resolution: "@colors/colors@npm:1.5.0"
|
resolution: "@colors/colors@npm:1.5.0"
|
||||||
@@ -3770,6 +3829,11 @@ __metadata:
|
|||||||
resolution: "@grafana/ui@workspace:packages/grafana-ui"
|
resolution: "@grafana/ui@workspace:packages/grafana-ui"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@babel/core": "npm:7.28.0"
|
"@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/css": "npm:11.13.5"
|
||||||
"@emotion/react": "npm:11.14.0"
|
"@emotion/react": "npm:11.14.0"
|
||||||
"@emotion/serialize": "npm:1.3.3"
|
"@emotion/serialize": "npm:1.3.3"
|
||||||
@@ -3781,6 +3845,7 @@ __metadata:
|
|||||||
"@grafana/i18n": "npm:12.4.0-pre"
|
"@grafana/i18n": "npm:12.4.0-pre"
|
||||||
"@grafana/schema": "npm:12.4.0-pre"
|
"@grafana/schema": "npm:12.4.0-pre"
|
||||||
"@hello-pangea/dnd": "npm:18.0.1"
|
"@hello-pangea/dnd": "npm:18.0.1"
|
||||||
|
"@lezer/highlight": "npm:^1.2.0"
|
||||||
"@monaco-editor/react": "npm:4.7.0"
|
"@monaco-editor/react": "npm:4.7.0"
|
||||||
"@popperjs/core": "npm:2.11.8"
|
"@popperjs/core": "npm:2.11.8"
|
||||||
"@rc-component/drawer": "npm:1.3.0"
|
"@rc-component/drawer": "npm:1.3.0"
|
||||||
@@ -5192,7 +5257,14 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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
|
version: 1.2.3
|
||||||
resolution: "@lezer/highlight@npm:1.2.3"
|
resolution: "@lezer/highlight@npm:1.2.3"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5210,6 +5282,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@linaria/core@npm:^4.5.4":
|
||||||
version: 4.5.4
|
version: 4.5.4
|
||||||
resolution: "@linaria/core@npm:4.5.4"
|
resolution: "@linaria/core@npm:4.5.4"
|
||||||
@@ -5387,6 +5468,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@mdx-js/react@npm:^3.0.0":
|
||||||
version: 3.0.1
|
version: 3.0.1
|
||||||
resolution: "@mdx-js/react@npm:3.0.1"
|
resolution: "@mdx-js/react@npm:3.0.1"
|
||||||
@@ -14930,6 +15018,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"croact-css-styled@npm:^1.1.9":
|
||||||
version: 1.1.9
|
version: 1.1.9
|
||||||
resolution: "croact-css-styled@npm:1.1.9"
|
resolution: "croact-css-styled@npm:1.1.9"
|
||||||
@@ -31798,6 +31893,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"style-search@npm:^0.1.0":
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
resolution: "style-search@npm:0.1.0"
|
resolution: "style-search@npm:0.1.0"
|
||||||
@@ -33839,6 +33941,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"w3c-xmlserializer@npm:^3.0.0":
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
resolution: "w3c-xmlserializer@npm:3.0.0"
|
resolution: "w3c-xmlserializer@npm:3.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user