Commit bbd24cd9 by Ryan McKinley Committed by GitHub

Monaco: add suggestions for template variables (#25921)

* now with suggestions

* using suggestions API

* using variable suggestions

* using variable suggestions

* show variables

* minor cleanup

* add @alpha warning

* Do not produce data variables if panel does not support queries

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
parent df72344d
import React from 'react'; import React from 'react';
import { withTheme } from '../../themes'; import { withTheme } from '../../themes';
import { Themeable } from '../../types'; import { Themeable } from '../../types';
import { KeyCode, editor, KeyMod } from 'monaco-editor/esm/vs/editor/editor.api'; import { CodeEditorProps } from './types';
import { registerSuggestions } from './suggestions';
import ReactMonaco from 'react-monaco-editor'; import ReactMonaco from 'react-monaco-editor';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
export interface CodeEditorProps { type Props = CodeEditorProps & Themeable;
value: string;
language: string;
width?: number | string;
height?: number | string;
readOnly?: boolean;
showMiniMap?: boolean;
showLineNumbers?: boolean;
/**
* Callback after the editor has mounted that gives you raw access to monaco
*
* @experimental
*/
onEditorDidMount?: (editor: editor.IStandaloneCodeEditor) => void;
/** Handler to be performed when editor is blurred */ class UnthemedCodeEditor extends React.PureComponent<Props> {
onBlur?: CodeEditorChangeHandler; completionCancel?: monaco.IDisposable;
/** Handler to be performed when Cmd/Ctrl+S is pressed */ componentWillUnmount() {
onSave?: CodeEditorChangeHandler; if (this.completionCancel) {
} console.log('dispose of the custom completion stuff');
this.completionCancel.dispose();
}
}
type Props = CodeEditorProps & Themeable; componentDidUpdate(oldProps: Props) {
const { getSuggestions, language } = this.props;
if (getSuggestions) {
// Language changed
if (language !== oldProps.language) {
if (this.completionCancel) {
this.completionCancel.dispose();
}
this.completionCancel = registerSuggestions(language, getSuggestions);
}
}
}
class UnthemedCodeEditor extends React.PureComponent<Props> { // This is replaced with a real function when the actual editor mounts
getEditorValue = () => ''; getEditorValue = () => '';
onBlur = () => { onBlur = () => {
...@@ -40,13 +41,20 @@ class UnthemedCodeEditor extends React.PureComponent<Props> { ...@@ -40,13 +41,20 @@ class UnthemedCodeEditor extends React.PureComponent<Props> {
} }
}; };
editorDidMount = (editor: editor.IStandaloneCodeEditor) => { editorWillMount = (m: typeof monaco) => {
const { language, getSuggestions } = this.props;
if (getSuggestions) {
this.completionCancel = registerSuggestions(language, getSuggestions);
}
};
editorDidMount = (editor: monaco.editor.IStandaloneCodeEditor) => {
const { onSave, onEditorDidMount } = this.props; const { onSave, onEditorDidMount } = this.props;
this.getEditorValue = () => editor.getValue(); this.getEditorValue = () => editor.getValue();
if (onSave) { if (onSave) {
editor.addCommand(KeyMod.CtrlCmd | KeyCode.KEY_S, () => { editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_S, () => {
onSave(this.getEditorValue()); onSave(this.getEditorValue());
}); });
} }
...@@ -61,7 +69,7 @@ class UnthemedCodeEditor extends React.PureComponent<Props> { ...@@ -61,7 +69,7 @@ class UnthemedCodeEditor extends React.PureComponent<Props> {
const value = this.props.value ?? ''; const value = this.props.value ?? '';
const longText = value.length > 100; const longText = value.length > 100;
const options: editor.IEditorConstructionOptions = { const options: monaco.editor.IEditorConstructionOptions = {
wordWrap: 'off', wordWrap: 'off',
codeLens: false, // not included in the bundle codeLens: false, // not included in the bundle
minimap: { minimap: {
...@@ -91,6 +99,7 @@ class UnthemedCodeEditor extends React.PureComponent<Props> { ...@@ -91,6 +99,7 @@ class UnthemedCodeEditor extends React.PureComponent<Props> {
theme={theme.isDark ? 'vs-dark' : 'vs-light'} theme={theme.isDark ? 'vs-dark' : 'vs-light'}
value={value} value={value}
options={options} options={options}
editorWillMount={this.editorWillMount}
editorDidMount={this.editorDidMount} editorDidMount={this.editorDidMount}
/> />
</div> </div>
...@@ -98,5 +107,4 @@ class UnthemedCodeEditor extends React.PureComponent<Props> { ...@@ -98,5 +107,4 @@ class UnthemedCodeEditor extends React.PureComponent<Props> {
} }
} }
export type CodeEditorChangeHandler = (value: string) => void;
export default withTheme(UnthemedCodeEditor); export default withTheme(UnthemedCodeEditor);
import React from 'react'; import React from 'react';
import { useAsyncDependency } from '../../utils/useAsyncDependency'; import { useAsyncDependency } from '../../utils/useAsyncDependency';
import { ErrorWithStack, LoadingPlaceholder } from '..'; import { ErrorWithStack, LoadingPlaceholder } from '..';
import { CodeEditorProps } from './CodeEditor'; import { CodeEditorProps } from './types';
export type CodeEditorChangeHandler = (value: string) => void;
export const CodeEditor: React.FC<CodeEditorProps> = props => { export const CodeEditor: React.FC<CodeEditorProps> = props => {
const { loading, error, dependency } = useAsyncDependency( const { loading, error, dependency } = useAsyncDependency(
...@@ -11,7 +9,7 @@ export const CodeEditor: React.FC<CodeEditorProps> = props => { ...@@ -11,7 +9,7 @@ export const CodeEditor: React.FC<CodeEditorProps> = props => {
); );
if (loading) { if (loading) {
return <LoadingPlaceholder text={'Loading...'} />; return <LoadingPlaceholder text={''} />;
} }
if (error) { if (error) {
......
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { CodeEditorSuggestionItem, CodeEditorSuggestionItemKind, CodeEditorSuggestionProvider } from './types';
function getCompletionItems(
prefix: string,
suggestions: CodeEditorSuggestionItem[],
range: monaco.IRange
): monaco.languages.CompletionItem[] {
const items: monaco.languages.CompletionItem[] = [];
for (const suggestion of suggestions) {
if (prefix && !suggestion.label.startsWith(prefix)) {
continue; // skip non-matching suggestions
}
items.push({
...suggestion,
kind: mapKinds(suggestion.kind),
range,
insertText: suggestion.insertText ?? suggestion.label,
});
}
return items;
}
function mapKinds(sug?: CodeEditorSuggestionItemKind): monaco.languages.CompletionItemKind {
switch (sug) {
case CodeEditorSuggestionItemKind.Method:
return monaco.languages.CompletionItemKind.Method;
case CodeEditorSuggestionItemKind.Field:
return monaco.languages.CompletionItemKind.Field;
case CodeEditorSuggestionItemKind.Property:
return monaco.languages.CompletionItemKind.Property;
case CodeEditorSuggestionItemKind.Constant:
return monaco.languages.CompletionItemKind.Constant;
case CodeEditorSuggestionItemKind.Text:
return monaco.languages.CompletionItemKind.Text;
}
return monaco.languages.CompletionItemKind.Text;
}
/**
* @alpha
*/
export function registerSuggestions(
language: string,
getSuggestions: CodeEditorSuggestionProvider
): monaco.IDisposable | undefined {
if (!language || !getSuggestions) {
return undefined;
}
return monaco.languages.registerCompletionItemProvider(language, {
triggerCharacters: ['$'],
provideCompletionItems: (model, position, context) => {
if (context.triggerCharacter === '$') {
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: position.column - 1,
endColumn: position.column,
};
return {
suggestions: getCompletionItems('$', getSuggestions(), range),
};
}
// find out if we are completing a property in the 'dependencies' object.
const lineText = model.getValueInRange({
startLineNumber: position.lineNumber,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column,
});
const idx = lineText.lastIndexOf('$');
if (idx >= 0) {
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: idx, // the last $ we found
endColumn: position.column,
};
return {
suggestions: getCompletionItems(lineText.substr(idx), getSuggestions(), range),
};
}
// Empty line that asked for suggestion
if (lineText.trim().length < 1) {
return {
suggestions: getCompletionItems('', getSuggestions(), {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: position.column,
endColumn: position.column,
}),
};
}
// console.log('complete?', lineText, context);
return undefined;
},
});
}
export type CodeEditorChangeHandler = (value: string) => void;
export type CodeEditorSuggestionProvider = () => CodeEditorSuggestionItem[];
export interface CodeEditorProps {
value: string;
language: string;
width?: number | string;
height?: number | string;
readOnly?: boolean;
showMiniMap?: boolean;
showLineNumbers?: boolean;
/**
* Callback after the editor has mounted that gives you raw access to monaco
*
* @experimental - real type is: monaco.editor.IStandaloneCodeEditor
*/
onEditorDidMount?: (editor: any) => void;
/** Handler to be performed when editor is blurred */
onBlur?: CodeEditorChangeHandler;
/** Handler to be performed when Cmd/Ctrl+S is pressed */
onSave?: CodeEditorChangeHandler;
/**
* Language agnostic suggestion completions -- typically for template variables
*/
getSuggestions?: CodeEditorSuggestionProvider;
}
/**
* @alpha
*/
export enum CodeEditorSuggestionItemKind {
Method = 'method',
Field = 'field',
Property = 'property',
Constant = 'constant',
Text = 'text',
}
/**
* @alpha
*/
export interface CodeEditorSuggestionItem {
/**
* The label of this completion item. By default
* this is also the text that is inserted when selecting
* this completion.
*/
label: string;
/**
* The kind of this completion item. An icon is chosen
* by the editor based on the kind.
*/
kind?: CodeEditorSuggestionItemKind;
/**
* A human-readable string with additional information
* about this item, like type or symbol information.
*/
detail?: string;
/**
* A human-readable string that represents a doc-comment.
*/
documentation?: string; // | IMarkdownString;
/**
* A string or snippet that should be inserted in a document when selecting
* this completion. When `falsy` the `label` is used.
*/
insertText?: string;
}
import { VariableSuggestion } from '@grafana/data';
import { CodeEditorSuggestionItem, CodeEditorSuggestionItemKind } from './types';
/**
* @alpha
*/
export function variableSuggestionToCodeEditorSuggestion(sug: VariableSuggestion): CodeEditorSuggestionItem {
const label = '${' + sug.value + '}';
const detail = sug.value === sug.label ? sug.origin : `${sug.label} / ${sug.origin}`;
return {
label,
kind: CodeEditorSuggestionItemKind.Property,
detail,
documentation: sug.documentation,
};
}
...@@ -34,7 +34,11 @@ export { FilterPill } from './FilterPill/FilterPill'; ...@@ -34,7 +34,11 @@ export { FilterPill } from './FilterPill/FilterPill';
export { ConfirmModal } from './ConfirmModal/ConfirmModal'; export { ConfirmModal } from './ConfirmModal/ConfirmModal';
export { QueryField } from './QueryField/QueryField'; export { QueryField } from './QueryField/QueryField';
// Code editor
export { CodeEditor } from './Monaco/CodeEditorLazy'; export { CodeEditor } from './Monaco/CodeEditorLazy';
export { CodeEditorSuggestionItem, CodeEditorSuggestionItemKind } from './Monaco/types';
export { variableSuggestionToCodeEditorSuggestion } from './Monaco/utils';
// TODO: namespace // TODO: namespace
export { Modal } from './Modal/Modal'; export { Modal } from './Modal/Modal';
......
...@@ -5,11 +5,13 @@ import { ...@@ -5,11 +5,13 @@ import {
PanelOptionsEditorItem, PanelOptionsEditorItem,
PanelPlugin, PanelPlugin,
StandardEditorContext, StandardEditorContext,
VariableSuggestionsScope,
} from '@grafana/data'; } from '@grafana/data';
import { get as lodashGet, set as lodashSet } from 'lodash'; import { get as lodashGet, set as lodashSet } from 'lodash';
import { Field, Label } from '@grafana/ui'; import { Field, Label } from '@grafana/ui';
import groupBy from 'lodash/groupBy'; import groupBy from 'lodash/groupBy';
import { OptionsGroup } from './OptionsGroup'; import { OptionsGroup } from './OptionsGroup';
import { getPanelOptionsVariableSuggestions } from 'app/features/panel/panellinks/link_srv';
interface PanelOptionsEditorProps<TOptions> { interface PanelOptionsEditorProps<TOptions> {
plugin: PanelPlugin; plugin: PanelPlugin;
...@@ -38,9 +40,12 @@ export const PanelOptionsEditor: React.FC<PanelOptionsEditorProps<any>> = ({ ...@@ -38,9 +40,12 @@ export const PanelOptionsEditor: React.FC<PanelOptionsEditorProps<any>> = ({
}; };
const context: StandardEditorContext<any> = { const context: StandardEditorContext<any> = {
data: data ?? [], data: data || [],
replaceVariables, replaceVariables,
options, options,
getSuggestions: (scope?: VariableSuggestionsScope) => {
return getPanelOptionsVariableSuggestions(plugin, data);
},
}; };
return ( return (
......
...@@ -19,6 +19,7 @@ import { ...@@ -19,6 +19,7 @@ import {
urlUtil, urlUtil,
textUtil, textUtil,
DataLink, DataLink,
PanelPlugin,
} from '@grafana/data'; } from '@grafana/data';
const timeRangeVars = [ const timeRangeVars = [
...@@ -231,6 +232,18 @@ export const getCalculationValueDataLinksVariableSuggestions = (dataFrames: Data ...@@ -231,6 +232,18 @@ export const getCalculationValueDataLinksVariableSuggestions = (dataFrames: Data
return [...seriesVars, ...fieldVars, ...valueVars, valueCalcVar, ...getPanelLinksVariableSuggestions()]; return [...seriesVars, ...fieldVars, ...valueVars, valueCalcVar, ...getPanelLinksVariableSuggestions()];
}; };
export const getPanelOptionsVariableSuggestions = (plugin: PanelPlugin, data?: DataFrame[]): VariableSuggestion[] => {
const dataVariables = plugin.meta.skipDataQuery ? [] : getDataFrameVars(data || []);
return [
...dataVariables, // field values
...templateSrv.getVariables().map(variable => ({
value: variable.name as string,
label: variable.name,
origin: VariableOrigin.Template,
})),
];
};
export interface LinkService { export interface LinkService {
getDataLinkUIModel: <T>(link: DataLink, scopedVars: ScopedVars, origin: T) => LinkModel<T>; getDataLinkUIModel: <T>(link: DataLink, scopedVars: ScopedVars, origin: T) => LinkModel<T>;
getAnchorInfo: (link: any) => any; getAnchorInfo: (link: any) => any;
......
import React, { FC, useMemo } from 'react'; import React, { FC, useMemo } from 'react';
import { css, cx } from 'emotion'; import { css, cx } from 'emotion';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import { CodeEditor, stylesFactory, useTheme } from '@grafana/ui'; import {
CodeEditor,
stylesFactory,
useTheme,
CodeEditorSuggestionItem,
variableSuggestionToCodeEditorSuggestion,
} from '@grafana/ui';
import { GrafanaTheme, StandardEditorProps } from '@grafana/data'; import { GrafanaTheme, StandardEditorProps } from '@grafana/data';
import { TextOptions } from './types'; import { TextOptions } from './types';
...@@ -10,6 +16,14 @@ export const TextPanelEditor: FC<StandardEditorProps<string, any, TextOptions>> ...@@ -10,6 +16,14 @@ export const TextPanelEditor: FC<StandardEditorProps<string, any, TextOptions>>
const language = useMemo(() => context.options?.mode ?? 'markdown', [context]); const language = useMemo(() => context.options?.mode ?? 'markdown', [context]);
const theme = useTheme(); const theme = useTheme();
const styles = getStyles(theme); const styles = getStyles(theme);
const getSuggestions = (): CodeEditorSuggestionItem[] => {
if (!context.getSuggestions) {
return [];
}
return context.getSuggestions().map(v => variableSuggestionToCodeEditorSuggestion(v));
};
return ( return (
<div className={cx(styles.editorBox)}> <div className={cx(styles.editorBox)}>
<AutoSizer disableHeight> <AutoSizer disableHeight>
...@@ -17,7 +31,6 @@ export const TextPanelEditor: FC<StandardEditorProps<string, any, TextOptions>> ...@@ -17,7 +31,6 @@ export const TextPanelEditor: FC<StandardEditorProps<string, any, TextOptions>>
if (width === 0) { if (width === 0) {
return null; return null;
} }
return ( return (
<CodeEditor <CodeEditor
value={value} value={value}
...@@ -28,6 +41,7 @@ export const TextPanelEditor: FC<StandardEditorProps<string, any, TextOptions>> ...@@ -28,6 +41,7 @@ export const TextPanelEditor: FC<StandardEditorProps<string, any, TextOptions>>
showMiniMap={false} showMiniMap={false}
showLineNumbers={false} showLineNumbers={false}
height="200px" height="200px"
getSuggestions={getSuggestions}
/> />
); );
}} }}
......
...@@ -80,7 +80,7 @@ module.exports = { ...@@ -80,7 +80,7 @@ module.exports = {
'!cursorUndo', '!cursorUndo',
'!dnd', '!dnd',
'!find', '!find',
'!folding', 'folding',
'!fontZoom', '!fontZoom',
'!format', '!format',
'!gotoError', '!gotoError',
...@@ -93,14 +93,14 @@ module.exports = { ...@@ -93,14 +93,14 @@ module.exports = {
'!linesOperations', '!linesOperations',
'!links', '!links',
'!multicursor', '!multicursor',
'!parameterHints', 'parameterHints',
'!quickCommand', '!quickCommand',
'!quickOutline', '!quickOutline',
'!referenceSearch', '!referenceSearch',
'!rename', '!rename',
'!smartSelect', '!smartSelect',
'!snippets', '!snippets',
'!suggest', 'suggest',
'!toggleHighContrast', '!toggleHighContrast',
'!toggleTabFocusMode', '!toggleTabFocusMode',
'!transpose', '!transpose',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment