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