diff --git a/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx b/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx index 766881ae51f..06bcfc0ef3e 100644 --- a/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx +++ b/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx @@ -1,36 +1,37 @@ import React from 'react'; import { withTheme } from '../../themes'; 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'; - -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 - */ - onEditorDidMount?: (editor: editor.IStandaloneCodeEditor) => void; - - /** Handler to be performed when editor is blurred */ - onBlur?: CodeEditorChangeHandler; - - /** Handler to be performed when Cmd/Ctrl+S is pressed */ - onSave?: CodeEditorChangeHandler; -} +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; type Props = CodeEditorProps & Themeable; class UnthemedCodeEditor extends React.PureComponent { + completionCancel?: monaco.IDisposable; + + componentWillUnmount() { + if (this.completionCancel) { + console.log('dispose of the custom completion stuff'); + this.completionCancel.dispose(); + } + } + + 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); + } + } + } + + // This is replaced with a real function when the actual editor mounts getEditorValue = () => ''; onBlur = () => { @@ -40,13 +41,20 @@ class UnthemedCodeEditor extends React.PureComponent { } }; - 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; this.getEditorValue = () => editor.getValue(); if (onSave) { - editor.addCommand(KeyMod.CtrlCmd | KeyCode.KEY_S, () => { + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_S, () => { onSave(this.getEditorValue()); }); } @@ -61,7 +69,7 @@ class UnthemedCodeEditor extends React.PureComponent { const value = this.props.value ?? ''; const longText = value.length > 100; - const options: editor.IEditorConstructionOptions = { + const options: monaco.editor.IEditorConstructionOptions = { wordWrap: 'off', codeLens: false, // not included in the bundle minimap: { @@ -91,6 +99,7 @@ class UnthemedCodeEditor extends React.PureComponent { theme={theme.isDark ? 'vs-dark' : 'vs-light'} value={value} options={options} + editorWillMount={this.editorWillMount} editorDidMount={this.editorDidMount} /> @@ -98,5 +107,4 @@ class UnthemedCodeEditor extends React.PureComponent { } } -export type CodeEditorChangeHandler = (value: string) => void; export default withTheme(UnthemedCodeEditor); diff --git a/packages/grafana-ui/src/components/Monaco/CodeEditorLazy.tsx b/packages/grafana-ui/src/components/Monaco/CodeEditorLazy.tsx index b32cd2c936c..34fc680a73a 100644 --- a/packages/grafana-ui/src/components/Monaco/CodeEditorLazy.tsx +++ b/packages/grafana-ui/src/components/Monaco/CodeEditorLazy.tsx @@ -1,9 +1,7 @@ import React from 'react'; import { useAsyncDependency } from '../../utils/useAsyncDependency'; import { ErrorWithStack, LoadingPlaceholder } from '..'; -import { CodeEditorProps } from './CodeEditor'; - -export type CodeEditorChangeHandler = (value: string) => void; +import { CodeEditorProps } from './types'; export const CodeEditor: React.FC = props => { const { loading, error, dependency } = useAsyncDependency( @@ -11,7 +9,7 @@ export const CodeEditor: React.FC = props => { ); if (loading) { - return ; + return ; } if (error) { diff --git a/packages/grafana-ui/src/components/Monaco/suggestions.ts b/packages/grafana-ui/src/components/Monaco/suggestions.ts new file mode 100644 index 00000000000..1070789943b --- /dev/null +++ b/packages/grafana-ui/src/components/Monaco/suggestions.ts @@ -0,0 +1,104 @@ +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; + }, + }); +} diff --git a/packages/grafana-ui/src/components/Monaco/types.ts b/packages/grafana-ui/src/components/Monaco/types.ts new file mode 100644 index 00000000000..d2dd6ea8978 --- /dev/null +++ b/packages/grafana-ui/src/components/Monaco/types.ts @@ -0,0 +1,77 @@ +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; +} diff --git a/packages/grafana-ui/src/components/Monaco/utils.ts b/packages/grafana-ui/src/components/Monaco/utils.ts new file mode 100644 index 00000000000..c2f50ab19fa --- /dev/null +++ b/packages/grafana-ui/src/components/Monaco/utils.ts @@ -0,0 +1,17 @@ +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, + }; +} diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index ecd4b303909..a7c1ae4b58d 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -34,7 +34,11 @@ export { FilterPill } from './FilterPill/FilterPill'; export { ConfirmModal } from './ConfirmModal/ConfirmModal'; export { QueryField } from './QueryField/QueryField'; + +// Code editor export { CodeEditor } from './Monaco/CodeEditorLazy'; +export { CodeEditorSuggestionItem, CodeEditorSuggestionItemKind } from './Monaco/types'; +export { variableSuggestionToCodeEditorSuggestion } from './Monaco/utils'; // TODO: namespace export { Modal } from './Modal/Modal'; diff --git a/public/app/features/dashboard/components/PanelEditor/PanelOptionsEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelOptionsEditor.tsx index 401862b6a9c..d3dc335c762 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelOptionsEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelOptionsEditor.tsx @@ -5,11 +5,13 @@ import { PanelOptionsEditorItem, PanelPlugin, StandardEditorContext, + VariableSuggestionsScope, } from '@grafana/data'; import { get as lodashGet, set as lodashSet } from 'lodash'; import { Field, Label } from '@grafana/ui'; import groupBy from 'lodash/groupBy'; import { OptionsGroup } from './OptionsGroup'; +import { getPanelOptionsVariableSuggestions } from 'app/features/panel/panellinks/link_srv'; interface PanelOptionsEditorProps { plugin: PanelPlugin; @@ -38,9 +40,12 @@ export const PanelOptionsEditor: React.FC> = ({ }; const context: StandardEditorContext = { - data: data ?? [], + data: data || [], replaceVariables, options, + getSuggestions: (scope?: VariableSuggestionsScope) => { + return getPanelOptionsVariableSuggestions(plugin, data); + }, }; return ( diff --git a/public/app/features/panel/panellinks/link_srv.ts b/public/app/features/panel/panellinks/link_srv.ts index 5302466a2d8..2d6a2d66eca 100644 --- a/public/app/features/panel/panellinks/link_srv.ts +++ b/public/app/features/panel/panellinks/link_srv.ts @@ -19,6 +19,7 @@ import { urlUtil, textUtil, DataLink, + PanelPlugin, } from '@grafana/data'; const timeRangeVars = [ @@ -231,6 +232,18 @@ export const getCalculationValueDataLinksVariableSuggestions = (dataFrames: Data 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 { getDataLinkUIModel: (link: DataLink, scopedVars: ScopedVars, origin: T) => LinkModel; getAnchorInfo: (link: any) => any; diff --git a/public/app/plugins/panel/text/TextPanelEditor.tsx b/public/app/plugins/panel/text/TextPanelEditor.tsx index 45882216fd3..d6e60bc8372 100644 --- a/public/app/plugins/panel/text/TextPanelEditor.tsx +++ b/public/app/plugins/panel/text/TextPanelEditor.tsx @@ -1,7 +1,13 @@ import React, { FC, useMemo } from 'react'; import { css, cx } from 'emotion'; 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 { TextOptions } from './types'; @@ -10,6 +16,14 @@ export const TextPanelEditor: FC> const language = useMemo(() => context.options?.mode ?? 'markdown', [context]); const theme = useTheme(); const styles = getStyles(theme); + + const getSuggestions = (): CodeEditorSuggestionItem[] => { + if (!context.getSuggestions) { + return []; + } + return context.getSuggestions().map(v => variableSuggestionToCodeEditorSuggestion(v)); + }; + return (
@@ -17,7 +31,6 @@ export const TextPanelEditor: FC> if (width === 0) { return null; } - return ( > showMiniMap={false} showLineNumbers={false} height="200px" + getSuggestions={getSuggestions} /> ); }} diff --git a/scripts/webpack/webpack.common.js b/scripts/webpack/webpack.common.js index 4fc9db77c33..23836e2443e 100644 --- a/scripts/webpack/webpack.common.js +++ b/scripts/webpack/webpack.common.js @@ -80,7 +80,7 @@ module.exports = { '!cursorUndo', '!dnd', '!find', - '!folding', + 'folding', '!fontZoom', '!format', '!gotoError', @@ -93,14 +93,14 @@ module.exports = { '!linesOperations', '!links', '!multicursor', - '!parameterHints', + 'parameterHints', '!quickCommand', '!quickOutline', '!referenceSearch', '!rename', '!smartSelect', '!snippets', - '!suggest', + 'suggest', '!toggleHighContrast', '!toggleTabFocusMode', '!transpose',