mirror of https://github.com/grafana/grafana
parent
96ffa9d797
commit
fefb2c2ba2
@ -0,0 +1,348 @@ |
||||
import Plain from 'slate-plain-serializer'; |
||||
|
||||
import QueryField from './query_field'; |
||||
// import debounce from './utils/debounce';
|
||||
// import {getNextCharacter} from './utils/dom';
|
||||
import debounce from 'app/features/explore/utils/debounce'; |
||||
import { getNextCharacter } from 'app/features/explore/utils/dom'; |
||||
|
||||
import { FUNCTIONS, KEYWORDS } from './kusto'; |
||||
// import '../sass/editor.base.scss';
|
||||
|
||||
|
||||
const TYPEAHEAD_DELAY = 500; |
||||
|
||||
interface Suggestion { |
||||
text: string; |
||||
deleteBackwards?: number; |
||||
type?: string; |
||||
} |
||||
|
||||
interface SuggestionGroup { |
||||
label: string; |
||||
items: Suggestion[]; |
||||
prefixMatch?: boolean; |
||||
skipFilter?: boolean; |
||||
} |
||||
|
||||
const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim(); |
||||
const wrapText = text => ({ text }); |
||||
|
||||
export default class KustoQueryField extends QueryField { |
||||
fields: any; |
||||
events: any; |
||||
|
||||
constructor(props, context) { |
||||
super(props, context); |
||||
|
||||
this.onTypeahead = debounce(this.onTypeahead, TYPEAHEAD_DELAY); |
||||
} |
||||
|
||||
componentDidMount() { |
||||
this.updateMenu(); |
||||
} |
||||
|
||||
onTypeahead = () => { |
||||
const selection = window.getSelection(); |
||||
if (selection.anchorNode) { |
||||
const wrapperNode = selection.anchorNode.parentElement; |
||||
if (wrapperNode === null) { |
||||
return; |
||||
} |
||||
const editorNode = wrapperNode.closest('.slate-query-field'); |
||||
if (!editorNode || this.state.value.isBlurred) { |
||||
// Not inside this editor
|
||||
return; |
||||
} |
||||
|
||||
// DOM ranges
|
||||
const range = selection.getRangeAt(0); |
||||
const text = selection.anchorNode.textContent; |
||||
if (text === null) { |
||||
return; |
||||
} |
||||
const offset = range.startOffset; |
||||
let prefix = cleanText(text.substr(0, offset)); |
||||
|
||||
// Model ranges
|
||||
const modelOffset = this.state.value.anchorOffset; |
||||
const modelPrefix = this.state.value.anchorText.text.slice(0, modelOffset); |
||||
|
||||
// Determine candidates by context
|
||||
let suggestionGroups: SuggestionGroup[] = []; |
||||
const wrapperClasses = wrapperNode.classList; |
||||
let typeaheadContext: string | null = null; |
||||
|
||||
if (wrapperClasses.contains('function-context')) { |
||||
typeaheadContext = 'context-function'; |
||||
if (this.fields) { |
||||
suggestionGroups = this._getFieldsSuggestions(); |
||||
} else { |
||||
this._fetchFields(); |
||||
return; |
||||
} |
||||
} else if (modelPrefix.match(/(facet\s$)/i)) { |
||||
typeaheadContext = 'context-facet'; |
||||
if (this.fields) { |
||||
suggestionGroups = this._getFieldsSuggestions(); |
||||
} else { |
||||
this._fetchFields(); |
||||
return; |
||||
} |
||||
} else if (modelPrefix.match(/(,\s*$)/)) { |
||||
typeaheadContext = 'context-multiple-fields'; |
||||
if (this.fields) { |
||||
suggestionGroups = this._getFieldsSuggestions(); |
||||
} else { |
||||
this._fetchFields(); |
||||
return; |
||||
} |
||||
} else if (modelPrefix.match(/(from\s$)/i)) { |
||||
typeaheadContext = 'context-from'; |
||||
if (this.events) { |
||||
suggestionGroups = this._getAfterFromSuggestions(); |
||||
} else { |
||||
this._fetchEvents(); |
||||
return; |
||||
} |
||||
} else if (modelPrefix.match(/(^select\s\w*$)/i)) { |
||||
typeaheadContext = 'context-select'; |
||||
if (this.fields) { |
||||
suggestionGroups = this._getAfterSelectSuggestions(); |
||||
} else { |
||||
this._fetchFields(); |
||||
return; |
||||
} |
||||
} else if ( |
||||
modelPrefix.match(/\)\s$/) || |
||||
modelPrefix.match(/SELECT ((?:\$?\w+\(?\w*\)?\,?\s*)+)([\)]\s|\b)/gi) |
||||
) { |
||||
typeaheadContext = 'context-after-function'; |
||||
suggestionGroups = this._getAfterFunctionSuggestions(); |
||||
} else if (modelPrefix.match(/from\s\S+\s\w*$/i)) { |
||||
prefix = ''; |
||||
typeaheadContext = 'context-since'; |
||||
suggestionGroups = this._getAfterEventSuggestions(); |
||||
// } else if (modelPrefix.match(/\d+\s\w*$/)) {
|
||||
// typeaheadContext = 'context-number';
|
||||
// suggestionGroups = this._getAfterNumberSuggestions();
|
||||
} else if (modelPrefix.match(/ago\b/i) || modelPrefix.match(/facet\b/i) || modelPrefix.match(/\$__timefilter\b/i)) { |
||||
typeaheadContext = 'context-timeseries'; |
||||
suggestionGroups = this._getAfterAgoSuggestions(); |
||||
} else if (prefix && !wrapperClasses.contains('argument')) { |
||||
typeaheadContext = 'context-builtin'; |
||||
suggestionGroups = this._getKeywordSuggestions(); |
||||
} else if (Plain.serialize(this.state.value) === '') { |
||||
typeaheadContext = 'context-new'; |
||||
suggestionGroups = this._getInitialSuggestions(); |
||||
} |
||||
|
||||
let results = 0; |
||||
prefix = prefix.toLowerCase(); |
||||
const filteredSuggestions = suggestionGroups.map(group => { |
||||
if (group.items && prefix && !group.skipFilter) { |
||||
group.items = group.items.filter(c => c.text.length >= prefix.length); |
||||
if (group.prefixMatch) { |
||||
group.items = group.items.filter(c => c.text.toLowerCase().indexOf(prefix) === 0); |
||||
} else { |
||||
group.items = group.items.filter(c => c.text.toLowerCase().indexOf(prefix) > -1); |
||||
} |
||||
} |
||||
results += group.items.length; |
||||
return group; |
||||
}) |
||||
.filter(group => group.items.length > 0); |
||||
|
||||
// console.log('onTypeahead', selection.anchorNode, wrapperClasses, text, offset, prefix, typeaheadContext);
|
||||
|
||||
this.setState({ |
||||
typeaheadPrefix: prefix, |
||||
typeaheadContext, |
||||
typeaheadText: text, |
||||
suggestions: results > 0 ? filteredSuggestions : [], |
||||
}); |
||||
} |
||||
} |
||||
|
||||
applyTypeahead(change, suggestion) { |
||||
const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state; |
||||
let suggestionText = suggestion.text || suggestion; |
||||
const move = 0; |
||||
|
||||
// Modify suggestion based on context
|
||||
|
||||
const nextChar = getNextCharacter(); |
||||
if (suggestion.type === 'function') { |
||||
if (!nextChar || nextChar !== '(') { |
||||
suggestionText += '('; |
||||
} |
||||
} else if (typeaheadContext === 'context-function') { |
||||
if (!nextChar || nextChar !== ')') { |
||||
suggestionText += ')'; |
||||
} |
||||
} else { |
||||
if (!nextChar || nextChar !== ' ') { |
||||
suggestionText += ' '; |
||||
} |
||||
} |
||||
|
||||
this.resetTypeahead(); |
||||
|
||||
// Remove the current, incomplete text and replace it with the selected suggestion
|
||||
const backward = suggestion.deleteBackwards || typeaheadPrefix.length; |
||||
const text = cleanText(typeaheadText); |
||||
const suffixLength = text.length - typeaheadPrefix.length; |
||||
const offset = typeaheadText.indexOf(typeaheadPrefix); |
||||
const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText); |
||||
const forward = midWord ? suffixLength + offset : 0; |
||||
|
||||
return change |
||||
.deleteBackward(backward) |
||||
.deleteForward(forward) |
||||
.insertText(suggestionText) |
||||
.move(move) |
||||
.focus(); |
||||
} |
||||
|
||||
private _getFieldsSuggestions(): SuggestionGroup[] { |
||||
return [ |
||||
{ |
||||
prefixMatch: true, |
||||
label: 'Fields', |
||||
items: this.fields.map(wrapText) |
||||
}, |
||||
{ |
||||
prefixMatch: true, |
||||
label: 'Variables', |
||||
items: this.props.templateVariables.map(wrapText) |
||||
} |
||||
]; |
||||
} |
||||
|
||||
private _getAfterFromSuggestions(): SuggestionGroup[] { |
||||
return [ |
||||
{ |
||||
skipFilter: true, |
||||
label: 'Events', |
||||
items: this.events.map(wrapText) |
||||
}, |
||||
{ |
||||
prefixMatch: true, |
||||
label: 'Variables', |
||||
items: this.props.templateVariables |
||||
.map(wrapText) |
||||
.map(suggestion => { |
||||
suggestion.deleteBackwards = 0; |
||||
return suggestion; |
||||
}) |
||||
} |
||||
]; |
||||
} |
||||
|
||||
private _getAfterSelectSuggestions(): SuggestionGroup[] { |
||||
return [ |
||||
{ |
||||
prefixMatch: true, |
||||
label: 'Fields', |
||||
items: this.fields.map(wrapText) |
||||
}, |
||||
{ |
||||
prefixMatch: true, |
||||
label: 'Functions', |
||||
items: FUNCTIONS.map((s: any) => { s.type = 'function'; return s; }) |
||||
}, |
||||
{ |
||||
prefixMatch: true, |
||||
label: 'Variables', |
||||
items: this.props.templateVariables.map(wrapText) |
||||
} |
||||
]; |
||||
} |
||||
|
||||
private _getAfterFunctionSuggestions(): SuggestionGroup[] { |
||||
return [{ |
||||
prefixMatch: true, |
||||
label: 'Keywords', |
||||
items: ['FROM'].map(wrapText) |
||||
}]; |
||||
} |
||||
|
||||
private _getAfterEventSuggestions(): SuggestionGroup[] { |
||||
return [ |
||||
{ |
||||
skipFilter: true, |
||||
label: 'Keywords', |
||||
items: ['SINCE'].map(wrapText) |
||||
.map((suggestion: any) => { |
||||
suggestion.deleteBackwards = 0; |
||||
return suggestion; |
||||
}) |
||||
}, |
||||
{ |
||||
skipFilter: true, |
||||
label: 'Macros', |
||||
items: ['$__timeFilter'].map(wrapText) |
||||
.map((suggestion: any) => { |
||||
suggestion.deleteBackwards = 0; |
||||
return suggestion; |
||||
}) |
||||
} |
||||
]; |
||||
} |
||||
|
||||
// private _getAfterNumberSuggestions(): SuggestionGroup[] {
|
||||
// return [{
|
||||
// prefixMatch: true,
|
||||
// label: 'Duration',
|
||||
// items: DURATION
|
||||
// .map(d => `${d} AGO`)
|
||||
// .map(wrapText)
|
||||
// }];
|
||||
// }
|
||||
|
||||
private _getAfterAgoSuggestions(): SuggestionGroup[] { |
||||
return [{ |
||||
prefixMatch: true, |
||||
label: 'Keywords', |
||||
items: ['TIMESERIES', 'COMPARE WITH', 'FACET'].map(wrapText) |
||||
}]; |
||||
} |
||||
|
||||
private _getKeywordSuggestions(): SuggestionGroup[] { |
||||
return [{ |
||||
prefixMatch: true, |
||||
label: 'Keywords', |
||||
items: KEYWORDS.map(wrapText) |
||||
}]; |
||||
} |
||||
|
||||
private _getInitialSuggestions(): SuggestionGroup[] { |
||||
// TODO: return datbase tables as an initial suggestion
|
||||
return [{ |
||||
prefixMatch: true, |
||||
label: 'Keywords', |
||||
items: KEYWORDS.map(wrapText) |
||||
}]; |
||||
} |
||||
|
||||
private async _fetchEvents() { |
||||
const query = 'events'; |
||||
const result = await this.request(query); |
||||
|
||||
if (result === undefined) { |
||||
this.events = []; |
||||
} else { |
||||
this.events = result; |
||||
} |
||||
setTimeout(this.onTypeahead, 0); |
||||
} |
||||
|
||||
private async _fetchFields() { |
||||
const query = 'fields'; |
||||
const result = await this.request(query); |
||||
|
||||
this.fields = result || []; |
||||
|
||||
setTimeout(this.onTypeahead, 0); |
||||
} |
||||
} |
||||
@ -0,0 +1,61 @@ |
||||
import KustoQueryField from './KustoQueryField'; |
||||
import Kusto from './kusto'; |
||||
|
||||
import React, {Component} from 'react'; |
||||
import coreModule from 'app/core/core_module'; |
||||
|
||||
|
||||
|
||||
class Editor extends Component<any, any> { |
||||
constructor(props) { |
||||
super(props); |
||||
this.state = { |
||||
edited: false, |
||||
query: props.query || '', |
||||
}; |
||||
} |
||||
|
||||
onChangeQuery = value => { |
||||
const { index, change } = this.props; |
||||
const { query } = this.state; |
||||
const edited = query !== value; |
||||
this.setState({ edited, query: value }); |
||||
if (change) { |
||||
change(value, index); |
||||
} |
||||
}; |
||||
|
||||
onPressEnter = () => { |
||||
const { execute } = this.props; |
||||
if (execute) { |
||||
execute(); |
||||
} |
||||
}; |
||||
|
||||
render() { |
||||
const { request, variables } = this.props; |
||||
const { edited, query } = this.state; |
||||
|
||||
return ( |
||||
<div className="gf-form-input" style={{height: 'auto'}}> |
||||
<KustoQueryField |
||||
initialQuery={edited ? null : query} |
||||
onPressEnter={this.onPressEnter} |
||||
onQueryChange={this.onChangeQuery} |
||||
prismLanguage="kusto" |
||||
prismDefinition={Kusto} |
||||
placeholder="Enter a query" |
||||
request={request} |
||||
templateVariables={variables} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
} |
||||
|
||||
coreModule.directive('kustoEditor', [ |
||||
'reactDirective', |
||||
reactDirective => { |
||||
return reactDirective(Editor, ['change', 'database', 'execute', 'query', 'request', 'variables']); |
||||
}, |
||||
]); |
||||
@ -0,0 +1,333 @@ |
||||
import PluginPrism from './slate-plugins/prism'; |
||||
// import PluginPrism from 'slate-prism';
|
||||
// import Prism from 'prismjs';
|
||||
|
||||
import BracesPlugin from 'app/features/explore/slate-plugins/braces'; |
||||
import ClearPlugin from 'app/features/explore/slate-plugins/clear'; |
||||
// Custom plugins (new line on Enter and run on Shift+Enter)
|
||||
import NewlinePlugin from './slate-plugins/newline'; |
||||
import RunnerPlugin from './slate-plugins/runner'; |
||||
|
||||
import Typeahead from './typeahead'; |
||||
|
||||
import { Block, Document, Text, Value } from 'slate'; |
||||
import { Editor } from 'slate-react'; |
||||
import Plain from 'slate-plain-serializer'; |
||||
import ReactDOM from 'react-dom'; |
||||
import React from 'react'; |
||||
import _ from 'lodash'; |
||||
|
||||
|
||||
function flattenSuggestions(s) { |
||||
return s ? s.reduce((acc, g) => acc.concat(g.items), []) : []; |
||||
} |
||||
|
||||
export const makeFragment = text => { |
||||
const lines = text.split('\n').map(line => |
||||
Block.create({ |
||||
type: 'paragraph', |
||||
nodes: [Text.create(line)], |
||||
}) |
||||
); |
||||
|
||||
const fragment = Document.create({ |
||||
nodes: lines, |
||||
}); |
||||
return fragment; |
||||
}; |
||||
|
||||
export const getInitialValue = query => Value.create({ document: makeFragment(query) }); |
||||
|
||||
class Portal extends React.Component<any, any> { |
||||
node: any; |
||||
|
||||
constructor(props) { |
||||
super(props); |
||||
const { index = 0, prefix = 'query' } = props; |
||||
this.node = document.createElement('div'); |
||||
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`); |
||||
document.body.appendChild(this.node); |
||||
} |
||||
|
||||
componentWillUnmount() { |
||||
document.body.removeChild(this.node); |
||||
} |
||||
|
||||
render() { |
||||
return ReactDOM.createPortal(this.props.children, this.node); |
||||
} |
||||
} |
||||
|
||||
class QueryField extends React.Component<any, any> { |
||||
menuEl: any; |
||||
plugins: any; |
||||
resetTimer: any; |
||||
|
||||
constructor(props, context) { |
||||
super(props, context); |
||||
|
||||
const { prismDefinition = {}, prismLanguage = 'kusto' } = props; |
||||
|
||||
this.plugins = [ |
||||
BracesPlugin(), |
||||
ClearPlugin(), |
||||
RunnerPlugin({ handler: props.onPressEnter }), |
||||
NewlinePlugin(), |
||||
PluginPrism({ definition: prismDefinition, language: prismLanguage }), |
||||
]; |
||||
|
||||
this.state = { |
||||
labelKeys: {}, |
||||
labelValues: {}, |
||||
suggestions: [], |
||||
typeaheadIndex: 0, |
||||
typeaheadPrefix: '', |
||||
value: getInitialValue(props.initialQuery || ''), |
||||
}; |
||||
} |
||||
|
||||
componentDidMount() { |
||||
this.updateMenu(); |
||||
} |
||||
|
||||
componentWillUnmount() { |
||||
clearTimeout(this.resetTimer); |
||||
} |
||||
|
||||
componentDidUpdate() { |
||||
this.updateMenu(); |
||||
} |
||||
|
||||
onChange = ({ value }) => { |
||||
const changed = value.document !== this.state.value.document; |
||||
this.setState({ value }, () => { |
||||
if (changed) { |
||||
this.onChangeQuery(); |
||||
} |
||||
}); |
||||
|
||||
window.requestAnimationFrame(this.onTypeahead); |
||||
} |
||||
|
||||
request = (url?) => { |
||||
if (this.props.request) { |
||||
return this.props.request(url); |
||||
} |
||||
return fetch(url); |
||||
} |
||||
|
||||
onChangeQuery = () => { |
||||
// Send text change to parent
|
||||
const { onQueryChange } = this.props; |
||||
if (onQueryChange) { |
||||
onQueryChange(Plain.serialize(this.state.value)); |
||||
} |
||||
} |
||||
|
||||
onKeyDown = (event, change) => { |
||||
const { typeaheadIndex, suggestions } = this.state; |
||||
|
||||
switch (event.key) { |
||||
case 'Escape': { |
||||
if (this.menuEl) { |
||||
event.preventDefault(); |
||||
event.stopPropagation(); |
||||
this.resetTypeahead(); |
||||
return true; |
||||
} |
||||
break; |
||||
} |
||||
|
||||
case ' ': { |
||||
if (event.ctrlKey) { |
||||
event.preventDefault(); |
||||
this.onTypeahead(); |
||||
return true; |
||||
} |
||||
break; |
||||
} |
||||
|
||||
case 'Tab': { |
||||
if (this.menuEl) { |
||||
// Dont blur input
|
||||
event.preventDefault(); |
||||
if (!suggestions || suggestions.length === 0) { |
||||
return undefined; |
||||
} |
||||
|
||||
// Get the currently selected suggestion
|
||||
const flattenedSuggestions = flattenSuggestions(suggestions); |
||||
const selected = Math.abs(typeaheadIndex); |
||||
const selectedIndex = selected % flattenedSuggestions.length || 0; |
||||
const suggestion = flattenedSuggestions[selectedIndex]; |
||||
|
||||
this.applyTypeahead(change, suggestion); |
||||
return true; |
||||
} |
||||
break; |
||||
} |
||||
|
||||
case 'ArrowDown': { |
||||
if (this.menuEl) { |
||||
// Select next suggestion
|
||||
event.preventDefault(); |
||||
this.setState({ typeaheadIndex: typeaheadIndex + 1 }); |
||||
} |
||||
break; |
||||
} |
||||
|
||||
case 'ArrowUp': { |
||||
if (this.menuEl) { |
||||
// Select previous suggestion
|
||||
event.preventDefault(); |
||||
this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) }); |
||||
} |
||||
break; |
||||
} |
||||
|
||||
default: { |
||||
// console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
|
||||
break; |
||||
} |
||||
} |
||||
return undefined; |
||||
} |
||||
|
||||
onTypeahead = (change?, item?) => { |
||||
return change || this.state.value.change(); |
||||
} |
||||
|
||||
applyTypeahead(change?, suggestion?): { value: object } { return { value: {} }; } |
||||
|
||||
resetTypeahead = () => { |
||||
this.setState({ |
||||
suggestions: [], |
||||
typeaheadIndex: 0, |
||||
typeaheadPrefix: '', |
||||
typeaheadContext: null, |
||||
}); |
||||
} |
||||
|
||||
handleBlur = () => { |
||||
const { onBlur } = this.props; |
||||
// If we dont wait here, menu clicks wont work because the menu
|
||||
// will be gone.
|
||||
this.resetTimer = setTimeout(this.resetTypeahead, 100); |
||||
if (onBlur) { |
||||
onBlur(); |
||||
} |
||||
} |
||||
|
||||
handleFocus = () => { |
||||
const { onFocus } = this.props; |
||||
if (onFocus) { |
||||
onFocus(); |
||||
} |
||||
} |
||||
|
||||
onClickItem = item => { |
||||
const { suggestions } = this.state; |
||||
if (!suggestions || suggestions.length === 0) { |
||||
return; |
||||
} |
||||
|
||||
// Get the currently selected suggestion
|
||||
const flattenedSuggestions = flattenSuggestions(suggestions); |
||||
const suggestion = _.find( |
||||
flattenedSuggestions, |
||||
suggestion => suggestion.display === item || suggestion.text === item |
||||
); |
||||
|
||||
// Manually triggering change
|
||||
const change = this.applyTypeahead(this.state.value.change(), suggestion); |
||||
this.onChange(change); |
||||
} |
||||
|
||||
updateMenu = () => { |
||||
const { suggestions } = this.state; |
||||
const menu = this.menuEl; |
||||
const selection = window.getSelection(); |
||||
const node = selection.anchorNode; |
||||
|
||||
// No menu, nothing to do
|
||||
if (!menu) { |
||||
return; |
||||
} |
||||
|
||||
// No suggestions or blur, remove menu
|
||||
const hasSuggesstions = suggestions && suggestions.length > 0; |
||||
if (!hasSuggesstions) { |
||||
menu.removeAttribute('style'); |
||||
return; |
||||
} |
||||
|
||||
// Align menu overlay to editor node
|
||||
if (node && node.parentElement) { |
||||
// Read from DOM
|
||||
const rect = node.parentElement.getBoundingClientRect(); |
||||
const scrollX = window.scrollX; |
||||
const scrollY = window.scrollY; |
||||
|
||||
// Write DOM
|
||||
requestAnimationFrame(() => { |
||||
menu.style.opacity = 1; |
||||
menu.style.top = `${rect.top + scrollY + rect.height + 4}px`; |
||||
menu.style.left = `${rect.left + scrollX - 2}px`; |
||||
}); |
||||
} |
||||
} |
||||
|
||||
menuRef = el => { |
||||
this.menuEl = el; |
||||
} |
||||
|
||||
renderMenu = () => { |
||||
const { portalPrefix } = this.props; |
||||
const { suggestions } = this.state; |
||||
const hasSuggesstions = suggestions && suggestions.length > 0; |
||||
if (!hasSuggesstions) { |
||||
return null; |
||||
} |
||||
|
||||
// Guard selectedIndex to be within the length of the suggestions
|
||||
let selectedIndex = Math.max(this.state.typeaheadIndex, 0); |
||||
const flattenedSuggestions = flattenSuggestions(suggestions); |
||||
selectedIndex = selectedIndex % flattenedSuggestions.length || 0; |
||||
const selectedKeys = (flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : []).map( |
||||
i => (typeof i === 'object' ? i.text : i) |
||||
); |
||||
|
||||
// Create typeahead in DOM root so we can later position it absolutely
|
||||
return ( |
||||
<Portal prefix={portalPrefix}> |
||||
<Typeahead |
||||
menuRef={this.menuRef} |
||||
selectedItems={selectedKeys} |
||||
onClickItem={this.onClickItem} |
||||
groupedItems={suggestions} |
||||
/> |
||||
</Portal> |
||||
); |
||||
} |
||||
|
||||
render() { |
||||
return ( |
||||
<div className="slate-query-field"> |
||||
{this.renderMenu()} |
||||
<Editor |
||||
autoCorrect={false} |
||||
onBlur={this.handleBlur} |
||||
onKeyDown={this.onKeyDown} |
||||
onChange={this.onChange} |
||||
onFocus={this.handleFocus} |
||||
placeholder={this.props.placeholder} |
||||
plugins={this.plugins} |
||||
spellCheck={false} |
||||
value={this.state.value} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export default QueryField; |
||||
@ -0,0 +1,35 @@ |
||||
function getIndent(text) { |
||||
let offset = text.length - text.trimLeft().length; |
||||
if (offset) { |
||||
let indent = text[0]; |
||||
while (--offset) { |
||||
indent += text[0]; |
||||
} |
||||
return indent; |
||||
} |
||||
return ''; |
||||
} |
||||
|
||||
export default function NewlinePlugin() { |
||||
return { |
||||
onKeyDown(event, change) { |
||||
const { value } = change; |
||||
if (!value.isCollapsed) { |
||||
return undefined; |
||||
} |
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) { |
||||
event.preventDefault(); |
||||
|
||||
const { startBlock } = value; |
||||
const currentLineText = startBlock.text; |
||||
const indent = getIndent(currentLineText); |
||||
|
||||
return change |
||||
.splitBlock() |
||||
.insertText(indent) |
||||
.focus(); |
||||
} |
||||
}, |
||||
}; |
||||
} |
||||
@ -0,0 +1,123 @@ |
||||
import React from 'react'; |
||||
import Prism from 'prismjs'; |
||||
|
||||
const TOKEN_MARK = 'prism-token'; |
||||
|
||||
export function setPrismTokens(language, field, values, alias = 'variable') { |
||||
Prism.languages[language][field] = { |
||||
alias, |
||||
pattern: new RegExp(`(?:^|\\s)(${values.join('|')})(?:$|\\s)`), |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Code-highlighting plugin based on Prism and |
||||
* https://github.com/ianstormtaylor/slate/blob/master/examples/code-highlighting/index.js
|
||||
* |
||||
* (Adapted to handle nested grammar definitions.) |
||||
*/ |
||||
|
||||
export default function PrismPlugin({ definition, language }) { |
||||
if (definition) { |
||||
// Don't override exising modified definitions
|
||||
Prism.languages[language] = Prism.languages[language] || definition; |
||||
} |
||||
|
||||
return { |
||||
/** |
||||
* Render a Slate mark with appropiate CSS class names |
||||
* |
||||
* @param {Object} props |
||||
* @return {Element} |
||||
*/ |
||||
|
||||
renderMark(props) { |
||||
const { children, mark } = props; |
||||
// Only apply spans to marks identified by this plugin
|
||||
if (mark.type !== TOKEN_MARK) { |
||||
return undefined; |
||||
} |
||||
const className = `token ${mark.data.get('types')}`; |
||||
return <span className={className}>{children}</span>; |
||||
}, |
||||
|
||||
/** |
||||
* Decorate code blocks with Prism.js highlighting. |
||||
* |
||||
* @param {Node} node |
||||
* @return {Array} |
||||
*/ |
||||
|
||||
decorateNode(node) { |
||||
if (node.type !== 'paragraph') { |
||||
return []; |
||||
} |
||||
|
||||
const texts = node.getTexts().toArray(); |
||||
const tstring = texts.map(t => t.text).join('\n'); |
||||
const grammar = Prism.languages[language]; |
||||
const tokens = Prism.tokenize(tstring, grammar); |
||||
const decorations: any[] = []; |
||||
let startText = texts.shift(); |
||||
let endText = startText; |
||||
let startOffset = 0; |
||||
let endOffset = 0; |
||||
let start = 0; |
||||
|
||||
function processToken(token, acc?) { |
||||
// Accumulate token types down the tree
|
||||
const types = `${acc || ''} ${token.type || ''} ${token.alias || ''}`; |
||||
|
||||
// Add mark for token node
|
||||
if (typeof token === 'string' || typeof token.content === 'string') { |
||||
startText = endText; |
||||
startOffset = endOffset; |
||||
|
||||
const content = typeof token === 'string' ? token : token.content; |
||||
const newlines = content.split('\n').length - 1; |
||||
const length = content.length - newlines; |
||||
const end = start + length; |
||||
|
||||
let available = startText.text.length - startOffset; |
||||
let remaining = length; |
||||
|
||||
endOffset = startOffset + remaining; |
||||
|
||||
while (available < remaining) { |
||||
endText = texts.shift(); |
||||
remaining = length - available; |
||||
available = endText.text.length; |
||||
endOffset = remaining; |
||||
} |
||||
|
||||
// Inject marks from up the tree (acc) as well
|
||||
if (typeof token !== 'string' || acc) { |
||||
const range = { |
||||
anchorKey: startText.key, |
||||
anchorOffset: startOffset, |
||||
focusKey: endText.key, |
||||
focusOffset: endOffset, |
||||
marks: [{ type: TOKEN_MARK, data: { types } }], |
||||
}; |
||||
|
||||
decorations.push(range); |
||||
} |
||||
|
||||
start = end; |
||||
} else if (token.content && token.content.length) { |
||||
// Tokens can be nested
|
||||
for (const subToken of token.content) { |
||||
processToken(subToken, types); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Process top-level tokens
|
||||
for (const token of tokens) { |
||||
processToken(token); |
||||
} |
||||
|
||||
return decorations; |
||||
}, |
||||
}; |
||||
} |
||||
@ -0,0 +1,14 @@ |
||||
export default function RunnerPlugin({ handler }) { |
||||
return { |
||||
onKeyDown(event) { |
||||
// Handle enter
|
||||
if (handler && event.key === 'Enter' && event.shiftKey) { |
||||
// Submit on Enter
|
||||
event.preventDefault(); |
||||
handler(event); |
||||
return true; |
||||
} |
||||
return undefined; |
||||
}, |
||||
}; |
||||
} |
||||
@ -0,0 +1,77 @@ |
||||
import React from 'react'; |
||||
|
||||
function scrollIntoView(el) { |
||||
if (!el || !el.offsetParent) { |
||||
return; |
||||
} |
||||
const container = el.offsetParent; |
||||
if (el.offsetTop > container.scrollTop + container.offsetHeight || el.offsetTop < container.scrollTop) { |
||||
container.scrollTop = el.offsetTop - container.offsetTop; |
||||
} |
||||
} |
||||
|
||||
class TypeaheadItem extends React.PureComponent<any, any> { |
||||
el: any; |
||||
componentDidUpdate(prevProps) { |
||||
if (this.props.isSelected && !prevProps.isSelected) { |
||||
scrollIntoView(this.el); |
||||
} |
||||
} |
||||
|
||||
getRef = el => { |
||||
this.el = el; |
||||
}; |
||||
|
||||
render() { |
||||
const { hint, isSelected, label, onClickItem } = this.props; |
||||
const className = isSelected ? 'typeahead-item typeahead-item__selected' : 'typeahead-item'; |
||||
const onClick = () => onClickItem(label); |
||||
return ( |
||||
<li ref={this.getRef} className={className} onClick={onClick}> |
||||
{label} |
||||
{hint && isSelected ? <div className="typeahead-item-hint">{hint}</div> : null} |
||||
</li> |
||||
); |
||||
} |
||||
} |
||||
|
||||
class TypeaheadGroup extends React.PureComponent<any, any> { |
||||
render() { |
||||
const { items, label, selected, onClickItem } = this.props; |
||||
return ( |
||||
<li className="typeahead-group"> |
||||
<div className="typeahead-group__title">{label}</div> |
||||
<ul className="typeahead-group__list"> |
||||
{items.map(item => { |
||||
const text = typeof item === 'object' ? item.text : item; |
||||
const label = typeof item === 'object' ? item.display || item.text : item; |
||||
return ( |
||||
<TypeaheadItem |
||||
key={text} |
||||
onClickItem={onClickItem} |
||||
isSelected={selected.indexOf(text) > -1} |
||||
hint={item.hint} |
||||
label={label} |
||||
/> |
||||
); |
||||
})} |
||||
</ul> |
||||
</li> |
||||
); |
||||
} |
||||
} |
||||
|
||||
class Typeahead extends React.PureComponent<any, any> { |
||||
render() { |
||||
const { groupedItems, menuRef, selectedItems, onClickItem } = this.props; |
||||
return ( |
||||
<ul className="typeahead" ref={menuRef}> |
||||
{groupedItems.map(g => ( |
||||
<TypeaheadGroup key={g.label} onClickItem={onClickItem} selected={selectedItems} {...g} /> |
||||
))} |
||||
</ul> |
||||
); |
||||
} |
||||
} |
||||
|
||||
export default Typeahead; |
||||
@ -1 +0,0 @@ |
||||
Object.assign({}); |
||||
@ -1,219 +0,0 @@ |
||||
// tslint:disable-next-line:no-reference
|
||||
///<reference path="../../../../../../node_modules/monaco-editor/monaco.d.ts" />
|
||||
|
||||
import KustoCodeEditor from './kusto_code_editor'; |
||||
import _ from 'lodash'; |
||||
|
||||
describe('KustoCodeEditor', () => { |
||||
let editor; |
||||
|
||||
describe('getCompletionItems', () => { |
||||
let completionItems; |
||||
let lineContent; |
||||
let model; |
||||
|
||||
beforeEach(() => { |
||||
(global as any).monaco = { |
||||
languages: { |
||||
CompletionItemKind: { |
||||
Keyword: '', |
||||
}, |
||||
}, |
||||
}; |
||||
model = { |
||||
getLineCount: () => 3, |
||||
getValueInRange: () => 'atable/n' + lineContent, |
||||
getLineContent: () => lineContent, |
||||
}; |
||||
|
||||
const StandaloneMock = jest.fn<monaco.editor.ICodeEditor>(); |
||||
editor = new KustoCodeEditor(null, 'TimeGenerated', () => {}, {}); |
||||
editor.codeEditor = new StandaloneMock(); |
||||
}); |
||||
|
||||
describe('when no where clause and no | in model text', () => { |
||||
beforeEach(() => { |
||||
lineContent = ' '; |
||||
const position = { lineNumber: 2, column: 2 }; |
||||
completionItems = editor.getCompletionItems(model, position); |
||||
}); |
||||
|
||||
it('should not return any grafana macros', () => { |
||||
expect(completionItems.length).toBe(0); |
||||
}); |
||||
}); |
||||
|
||||
describe('when no where clause in model text', () => { |
||||
beforeEach(() => { |
||||
lineContent = '| '; |
||||
const position = { lineNumber: 2, column: 3 }; |
||||
completionItems = editor.getCompletionItems(model, position); |
||||
}); |
||||
|
||||
it('should return grafana macros for where and timefilter', () => { |
||||
expect(completionItems.length).toBe(1); |
||||
|
||||
expect(completionItems[0].label).toBe('where $__timeFilter(timeColumn)'); |
||||
expect(completionItems[0].insertText.value).toBe('where \\$__timeFilter(${0:TimeGenerated})'); |
||||
}); |
||||
}); |
||||
|
||||
describe('when on line with where clause', () => { |
||||
beforeEach(() => { |
||||
lineContent = '| where Test == 2 and '; |
||||
const position = { lineNumber: 2, column: 23 }; |
||||
completionItems = editor.getCompletionItems(model, position); |
||||
}); |
||||
|
||||
it('should return grafana macros and variables', () => { |
||||
expect(completionItems.length).toBe(4); |
||||
|
||||
expect(completionItems[0].label).toBe('$__timeFilter(timeColumn)'); |
||||
expect(completionItems[0].insertText.value).toBe('\\$__timeFilter(${0:TimeGenerated})'); |
||||
|
||||
expect(completionItems[1].label).toBe('$__from'); |
||||
expect(completionItems[1].insertText.value).toBe('\\$__from'); |
||||
|
||||
expect(completionItems[2].label).toBe('$__to'); |
||||
expect(completionItems[2].insertText.value).toBe('\\$__to'); |
||||
|
||||
expect(completionItems[3].label).toBe('$__interval'); |
||||
expect(completionItems[3].insertText.value).toBe('\\$__interval'); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('onDidChangeCursorSelection', () => { |
||||
const keyboardEvent = { |
||||
selection: { |
||||
startLineNumber: 4, |
||||
startColumn: 26, |
||||
endLineNumber: 4, |
||||
endColumn: 31, |
||||
selectionStartLineNumber: 4, |
||||
selectionStartColumn: 26, |
||||
positionLineNumber: 4, |
||||
positionColumn: 31, |
||||
}, |
||||
secondarySelections: [], |
||||
source: 'keyboard', |
||||
reason: 3, |
||||
}; |
||||
|
||||
const modelChangedEvent = { |
||||
selection: { |
||||
startLineNumber: 2, |
||||
startColumn: 1, |
||||
endLineNumber: 3, |
||||
endColumn: 3, |
||||
selectionStartLineNumber: 2, |
||||
selectionStartColumn: 1, |
||||
positionLineNumber: 3, |
||||
positionColumn: 3, |
||||
}, |
||||
secondarySelections: [], |
||||
source: 'modelChange', |
||||
reason: 2, |
||||
}; |
||||
|
||||
describe('suggestion trigger', () => { |
||||
let suggestionTriggered; |
||||
let lineContent = ''; |
||||
|
||||
beforeEach(() => { |
||||
(global as any).monaco = { |
||||
languages: { |
||||
CompletionItemKind: { |
||||
Keyword: '', |
||||
}, |
||||
}, |
||||
editor: { |
||||
CursorChangeReason: { |
||||
NotSet: 0, |
||||
ContentFlush: 1, |
||||
RecoverFromMarkers: 2, |
||||
Explicit: 3, |
||||
Paste: 4, |
||||
Undo: 5, |
||||
Redo: 6, |
||||
}, |
||||
}, |
||||
}; |
||||
const StandaloneMock = jest.fn<monaco.editor.ICodeEditor>(() => ({ |
||||
getModel: () => { |
||||
return { |
||||
getLineCount: () => 3, |
||||
getLineContent: () => lineContent, |
||||
}; |
||||
}, |
||||
})); |
||||
|
||||
editor = new KustoCodeEditor(null, 'TimeGenerated', () => {}, {}); |
||||
editor.codeEditor = new StandaloneMock(); |
||||
editor.triggerSuggestions = () => { |
||||
suggestionTriggered = true; |
||||
}; |
||||
}); |
||||
|
||||
describe('when model change event, reason is RecoverFromMarkers and there is a space after', () => { |
||||
beforeEach(() => { |
||||
suggestionTriggered = false; |
||||
lineContent = '| '; |
||||
editor.onDidChangeCursorSelection(modelChangedEvent); |
||||
}); |
||||
|
||||
it('should trigger suggestion', () => { |
||||
expect(suggestionTriggered).toBeTruthy(); |
||||
}); |
||||
}); |
||||
|
||||
describe('when not model change event', () => { |
||||
beforeEach(() => { |
||||
suggestionTriggered = false; |
||||
editor.onDidChangeCursorSelection(keyboardEvent); |
||||
}); |
||||
|
||||
it('should not trigger suggestion', () => { |
||||
expect(suggestionTriggered).toBeFalsy(); |
||||
}); |
||||
}); |
||||
|
||||
describe('when model change event but with incorrect reason', () => { |
||||
beforeEach(() => { |
||||
suggestionTriggered = false; |
||||
const modelChangedWithInvalidReason = _.cloneDeep(modelChangedEvent); |
||||
modelChangedWithInvalidReason.reason = 5; |
||||
editor.onDidChangeCursorSelection(modelChangedWithInvalidReason); |
||||
}); |
||||
|
||||
it('should not trigger suggestion', () => { |
||||
expect(suggestionTriggered).toBeFalsy(); |
||||
}); |
||||
}); |
||||
|
||||
describe('when model change event but with no space after', () => { |
||||
beforeEach(() => { |
||||
suggestionTriggered = false; |
||||
lineContent = '|'; |
||||
editor.onDidChangeCursorSelection(modelChangedEvent); |
||||
}); |
||||
|
||||
it('should not trigger suggestion', () => { |
||||
expect(suggestionTriggered).toBeFalsy(); |
||||
}); |
||||
}); |
||||
|
||||
describe('when model change event but with no space after', () => { |
||||
beforeEach(() => { |
||||
suggestionTriggered = false; |
||||
lineContent = '|'; |
||||
editor.onDidChangeCursorSelection(modelChangedEvent); |
||||
}); |
||||
|
||||
it('should not trigger suggestion', () => { |
||||
expect(suggestionTriggered).toBeFalsy(); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -1,332 +0,0 @@ |
||||
// tslint:disable-next-line:no-reference
|
||||
///<reference path="../../../../../../node_modules/monaco-editor/monaco.d.ts" />
|
||||
|
||||
import _ from 'lodash'; |
||||
|
||||
export interface SuggestionController { |
||||
_model: any; |
||||
} |
||||
|
||||
export default class KustoCodeEditor { |
||||
codeEditor: monaco.editor.IStandaloneCodeEditor; |
||||
completionItemProvider: monaco.IDisposable; |
||||
signatureHelpProvider: monaco.IDisposable; |
||||
|
||||
splitWithNewLineRegex = /[^\n]+\n?|\n/g; |
||||
newLineRegex = /\r?\n/; |
||||
startsWithKustoPipeRegex = /^\|\s*/g; |
||||
kustoPipeRegexStrict = /^\|\s*$/g; |
||||
|
||||
constructor( |
||||
private containerDiv: any, |
||||
private defaultTimeField: string, |
||||
private getSchema: () => any, |
||||
private config: any |
||||
) {} |
||||
|
||||
initMonaco(scope) { |
||||
const themeName = this.config.bootData.user.lightTheme ? 'grafana-light' : 'vs-dark'; |
||||
|
||||
monaco.editor.defineTheme('grafana-light', { |
||||
base: 'vs', |
||||
inherit: true, |
||||
rules: [ |
||||
{ token: 'comment', foreground: '008000' }, |
||||
{ token: 'variable.predefined', foreground: '800080' }, |
||||
{ token: 'function', foreground: '0000FF' }, |
||||
{ token: 'operator.sql', foreground: 'FF4500' }, |
||||
{ token: 'string', foreground: 'B22222' }, |
||||
{ token: 'operator.scss', foreground: '0000FF' }, |
||||
{ token: 'variable', foreground: 'C71585' }, |
||||
{ token: 'variable.parameter', foreground: '9932CC' }, |
||||
{ token: '', foreground: '000000' }, |
||||
{ token: 'type', foreground: '0000FF' }, |
||||
{ token: 'tag', foreground: '0000FF' }, |
||||
{ token: 'annotation', foreground: '2B91AF' }, |
||||
{ token: 'keyword', foreground: '0000FF' }, |
||||
{ token: 'number', foreground: '191970' }, |
||||
{ token: 'annotation', foreground: '9400D3' }, |
||||
{ token: 'invalid', background: 'cd3131' }, |
||||
], |
||||
colors: { |
||||
'textCodeBlock.background': '#FFFFFF', |
||||
}, |
||||
}); |
||||
|
||||
monaco.languages['kusto'].kustoDefaults.setLanguageSettings({ |
||||
includeControlCommands: true, |
||||
newlineAfterPipe: true, |
||||
useIntellisenseV2: false, |
||||
useSemanticColorization: true, |
||||
}); |
||||
|
||||
this.codeEditor = monaco.editor.create(this.containerDiv, { |
||||
value: scope.content || 'Write your query here', |
||||
language: 'kusto', |
||||
// language: 'go',
|
||||
selectionHighlight: false, |
||||
theme: themeName, |
||||
folding: true, |
||||
lineNumbers: 'off', |
||||
lineHeight: 16, |
||||
suggestFontSize: 13, |
||||
dragAndDrop: false, |
||||
occurrencesHighlight: false, |
||||
minimap: { |
||||
enabled: false, |
||||
}, |
||||
renderIndentGuides: false, |
||||
wordWrap: 'on', |
||||
}); |
||||
this.codeEditor.layout(); |
||||
|
||||
if (monaco.editor.getModels().length === 1) { |
||||
this.completionItemProvider = monaco.languages.registerCompletionItemProvider('kusto', { |
||||
triggerCharacters: ['.', ' '], |
||||
provideCompletionItems: this.getCompletionItems.bind(this), |
||||
}); |
||||
|
||||
this.signatureHelpProvider = monaco.languages.registerSignatureHelpProvider('kusto', { |
||||
signatureHelpTriggerCharacters: ['(', ')'], |
||||
provideSignatureHelp: this.getSignatureHelp.bind(this), |
||||
}); |
||||
} |
||||
|
||||
this.codeEditor.createContextKey('readyToExecute', true); |
||||
|
||||
this.codeEditor.onDidChangeCursorSelection(event => { |
||||
this.onDidChangeCursorSelection(event); |
||||
}); |
||||
|
||||
this.getSchema().then(schema => { |
||||
if (!schema) { |
||||
return; |
||||
} |
||||
|
||||
monaco.languages['kusto'].getKustoWorker().then(workerAccessor => { |
||||
const model = this.codeEditor.getModel(); |
||||
if (!model) { |
||||
return; |
||||
} |
||||
|
||||
workerAccessor(model.uri).then(worker => { |
||||
const dbName = Object.keys(schema.Databases).length > 0 ? Object.keys(schema.Databases)[0] : ''; |
||||
worker.setSchemaFromShowSchema(schema, 'https://help.kusto.windows.net', dbName); |
||||
this.codeEditor.layout(); |
||||
}); |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
setOnDidChangeModelContent(listener) { |
||||
this.codeEditor.onDidChangeModelContent(listener); |
||||
} |
||||
|
||||
disposeMonaco() { |
||||
if (this.completionItemProvider) { |
||||
try { |
||||
this.completionItemProvider.dispose(); |
||||
} catch (e) { |
||||
console.error('Failed to dispose the completion item provider.', e); |
||||
} |
||||
} |
||||
if (this.signatureHelpProvider) { |
||||
try { |
||||
this.signatureHelpProvider.dispose(); |
||||
} catch (e) { |
||||
console.error('Failed to dispose the signature help provider.', e); |
||||
} |
||||
} |
||||
if (this.codeEditor) { |
||||
try { |
||||
this.codeEditor.dispose(); |
||||
} catch (e) { |
||||
console.error('Failed to dispose the editor component.', e); |
||||
} |
||||
} |
||||
} |
||||
|
||||
addCommand(keybinding: number, commandFunc: monaco.editor.ICommandHandler) { |
||||
this.codeEditor.addCommand(keybinding, commandFunc, 'readyToExecute'); |
||||
} |
||||
|
||||
getValue() { |
||||
return this.codeEditor.getValue(); |
||||
} |
||||
|
||||
toSuggestionController(srv: monaco.editor.IEditorContribution): SuggestionController { |
||||
return srv as any; |
||||
} |
||||
|
||||
setEditorContent(value) { |
||||
this.codeEditor.setValue(value); |
||||
} |
||||
|
||||
getCompletionItems(model: monaco.editor.IReadOnlyModel, position: monaco.Position) { |
||||
const timeFilterDocs = |
||||
'##### Macro that uses the selected timerange in Grafana to filter the query.\n\n' + |
||||
'- `$__timeFilter()` -> Uses the ' + |
||||
this.defaultTimeField + |
||||
' column\n\n' + |
||||
'- `$__timeFilter(datetimeColumn)` -> Uses the specified datetime column to build the query.'; |
||||
|
||||
const textUntilPosition = model.getValueInRange({ |
||||
startLineNumber: 1, |
||||
startColumn: 1, |
||||
endLineNumber: position.lineNumber, |
||||
endColumn: position.column, |
||||
}); |
||||
|
||||
if (!_.includes(textUntilPosition, '|')) { |
||||
return []; |
||||
} |
||||
|
||||
if (!_.includes(textUntilPosition.toLowerCase(), 'where')) { |
||||
return [ |
||||
{ |
||||
label: 'where $__timeFilter(timeColumn)', |
||||
kind: monaco.languages.CompletionItemKind.Keyword, |
||||
insertText: { |
||||
value: 'where \\$__timeFilter(${0:' + this.defaultTimeField + '})', |
||||
}, |
||||
documentation: { |
||||
value: timeFilterDocs, |
||||
}, |
||||
}, |
||||
]; |
||||
} |
||||
|
||||
if (_.includes(model.getLineContent(position.lineNumber).toLowerCase(), 'where')) { |
||||
return [ |
||||
{ |
||||
label: '$__timeFilter(timeColumn)', |
||||
kind: monaco.languages.CompletionItemKind.Keyword, |
||||
insertText: { |
||||
value: '\\$__timeFilter(${0:' + this.defaultTimeField + '})', |
||||
}, |
||||
documentation: { |
||||
value: timeFilterDocs, |
||||
}, |
||||
}, |
||||
{ |
||||
label: '$__from', |
||||
kind: monaco.languages.CompletionItemKind.Keyword, |
||||
insertText: { |
||||
value: `\\$__from`, |
||||
}, |
||||
documentation: { |
||||
value: |
||||
'Built-in variable that returns the from value of the selected timerange in Grafana.\n\n' + |
||||
'Example: `where ' + |
||||
this.defaultTimeField + |
||||
' > $__from` ', |
||||
}, |
||||
}, |
||||
{ |
||||
label: '$__to', |
||||
kind: monaco.languages.CompletionItemKind.Keyword, |
||||
insertText: { |
||||
value: `\\$__to`, |
||||
}, |
||||
documentation: { |
||||
value: |
||||
'Built-in variable that returns the to value of the selected timerange in Grafana.\n\n' + |
||||
'Example: `where ' + |
||||
this.defaultTimeField + |
||||
' < $__to` ', |
||||
}, |
||||
}, |
||||
{ |
||||
label: '$__interval', |
||||
kind: monaco.languages.CompletionItemKind.Keyword, |
||||
insertText: { |
||||
value: `\\$__interval`, |
||||
}, |
||||
documentation: { |
||||
value: |
||||
'##### Built-in variable that returns an automatic time grain suitable for the current timerange.\n\n' + |
||||
'Used with the bin() function - `bin(' + |
||||
this.defaultTimeField + |
||||
', $__interval)` \n\n' + |
||||
'[Grafana docs](http://docs.grafana.org/reference/templating/#the-interval-variable)', |
||||
}, |
||||
}, |
||||
]; |
||||
} |
||||
|
||||
return []; |
||||
} |
||||
|
||||
getSignatureHelp(model: monaco.editor.IReadOnlyModel, position: monaco.Position, token: monaco.CancellationToken) { |
||||
const textUntilPosition = model.getValueInRange({ |
||||
startLineNumber: position.lineNumber, |
||||
startColumn: position.column - 14, |
||||
endLineNumber: position.lineNumber, |
||||
endColumn: position.column, |
||||
}); |
||||
|
||||
if (textUntilPosition !== '$__timeFilter(') { |
||||
return {} as monaco.languages.SignatureHelp; |
||||
} |
||||
|
||||
const signature: monaco.languages.SignatureHelp = { |
||||
activeParameter: 0, |
||||
activeSignature: 0, |
||||
signatures: [ |
||||
{ |
||||
label: '$__timeFilter(timeColumn)', |
||||
parameters: [ |
||||
{ |
||||
label: 'timeColumn', |
||||
documentation: |
||||
'Default is ' + |
||||
this.defaultTimeField + |
||||
' column. Datetime column to filter data using the selected date range. ', |
||||
}, |
||||
], |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
return signature; |
||||
} |
||||
|
||||
onDidChangeCursorSelection(event) { |
||||
if (event.source !== 'modelChange' || event.reason !== monaco.editor.CursorChangeReason.RecoverFromMarkers) { |
||||
return; |
||||
} |
||||
const lastChar = this.getCharAt(event.selection.positionLineNumber, event.selection.positionColumn - 1); |
||||
|
||||
if (lastChar !== ' ') { |
||||
return; |
||||
} |
||||
|
||||
this.triggerSuggestions(); |
||||
} |
||||
|
||||
triggerSuggestions() { |
||||
const suggestController = this.codeEditor.getContribution('editor.contrib.suggestController'); |
||||
if (!suggestController) { |
||||
return; |
||||
} |
||||
|
||||
const convertedController = this.toSuggestionController(suggestController); |
||||
|
||||
convertedController._model.cancel(); |
||||
setTimeout(() => { |
||||
convertedController._model.trigger(true); |
||||
}, 10); |
||||
} |
||||
|
||||
getCharAt(lineNumber: number, column: number) { |
||||
const model = this.codeEditor.getModel(); |
||||
if (model.getLineCount() === 0 || model.getLineCount() < lineNumber) { |
||||
return ''; |
||||
} |
||||
const line = model.getLineContent(lineNumber); |
||||
if (line.length < column || column < 1) { |
||||
return ''; |
||||
} |
||||
return line[column - 1]; |
||||
} |
||||
} |
||||
@ -1,105 +0,0 @@ |
||||
// tslint:disable-next-line:no-reference
|
||||
// ///<reference path="../../../../../../node_modules/monaco-editor/monaco.d.ts" />
|
||||
|
||||
import angular from 'angular'; |
||||
import KustoCodeEditor from './kusto_code_editor'; |
||||
import config from 'app/core/config'; |
||||
|
||||
/** |
||||
* Load monaco code editor and its' dependencies as a separate webpack chunk. |
||||
*/ |
||||
function importMonaco() { |
||||
return import( |
||||
/* webpackChunkName: "monaco" */ |
||||
'./monaco-loader' |
||||
).then(monaco => { |
||||
return monaco; |
||||
}).catch(error => { |
||||
console.error('An error occurred while loading monaco-kusto:\n', error); |
||||
}); |
||||
} |
||||
|
||||
const editorTemplate = `<div id="content" tabindex="0" style="width: 100%; height: 120px"></div>`; |
||||
|
||||
function link(scope, elem, attrs) { |
||||
const containerDiv = elem.find('#content')[0]; |
||||
|
||||
if (!(global as any).monaco) { |
||||
// (global as any).System.import(`./${scope.pluginBaseUrl}/lib/monaco.min.js`).then(() => {
|
||||
importMonaco().then(() => { |
||||
setTimeout(() => { |
||||
initMonaco(containerDiv, scope); |
||||
}, 1); |
||||
}); |
||||
} else { |
||||
setTimeout(() => { |
||||
initMonaco(containerDiv, scope); |
||||
}, 1); |
||||
} |
||||
|
||||
containerDiv.onblur = () => { |
||||
scope.onChange(); |
||||
}; |
||||
|
||||
containerDiv.onkeydown = evt => { |
||||
if (evt.key === 'Escape') { |
||||
evt.stopPropagation(); |
||||
return true; |
||||
} |
||||
|
||||
return undefined; |
||||
}; |
||||
|
||||
function initMonaco(containerDiv, scope) { |
||||
const kustoCodeEditor = new KustoCodeEditor(containerDiv, scope.defaultTimeField, scope.getSchema, config); |
||||
|
||||
kustoCodeEditor.initMonaco(scope); |
||||
|
||||
/* tslint:disable:no-bitwise */ |
||||
kustoCodeEditor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => { |
||||
const newValue = kustoCodeEditor.getValue(); |
||||
scope.content = newValue; |
||||
scope.onChange(); |
||||
}); |
||||
/* tslint:enable:no-bitwise */ |
||||
|
||||
// Sync with outer scope - update editor content if model has been changed from outside of directive.
|
||||
scope.$watch('content', (newValue, oldValue) => { |
||||
const editorValue = kustoCodeEditor.getValue(); |
||||
if (newValue !== editorValue && newValue !== oldValue) { |
||||
scope.$$postDigest(() => { |
||||
kustoCodeEditor.setEditorContent(newValue); |
||||
}); |
||||
} |
||||
}); |
||||
|
||||
kustoCodeEditor.setOnDidChangeModelContent(() => { |
||||
scope.$apply(() => { |
||||
const newValue = kustoCodeEditor.getValue(); |
||||
scope.content = newValue; |
||||
}); |
||||
}); |
||||
|
||||
scope.$on('$destroy', () => { |
||||
kustoCodeEditor.disposeMonaco(); |
||||
}); |
||||
} |
||||
} |
||||
|
||||
/** @ngInject */ |
||||
export function kustoMonacoEditorDirective() { |
||||
return { |
||||
restrict: 'E', |
||||
template: editorTemplate, |
||||
scope: { |
||||
content: '=', |
||||
onChange: '&', |
||||
getSchema: '&', |
||||
defaultTimeField: '@', |
||||
pluginBaseUrl: '@', |
||||
}, |
||||
link: link, |
||||
}; |
||||
} |
||||
|
||||
angular.module('grafana.controllers').directive('kustoMonacoEditor', kustoMonacoEditorDirective); |
||||
@ -1,21 +0,0 @@ |
||||
// tslint:disable:no-reference
|
||||
// ///<reference path="../../../../../../node_modules/@alexanderzobnin/monaco-kusto/release/min/monaco.d.ts" />
|
||||
|
||||
// (1) Desired editor features:
|
||||
import "monaco-editor/esm/vs/editor/browser/controller/coreCommands.js"; |
||||
import 'monaco-editor/esm/vs/editor/browser/widget/codeEditorWidget.js'; |
||||
import 'monaco-editor/esm/vs/editor/contrib/contextmenu/contextmenu.js'; |
||||
import "monaco-editor/esm/vs/editor/contrib/find/findController.js"; |
||||
import 'monaco-editor/esm/vs/editor/contrib/folding/folding.js'; |
||||
import 'monaco-editor/esm/vs/editor/contrib/format/formatActions.js'; |
||||
import 'monaco-editor/esm/vs/editor/contrib/multicursor/multicursor.js'; |
||||
import 'monaco-editor/esm/vs/editor/contrib/suggest/suggestController.js'; |
||||
import 'monaco-editor/esm/vs/editor/contrib/wordHighlighter/wordHighlighter.js'; |
||||
import 'monaco-editor/esm/vs/editor/standalone/browser/iPadShowKeyboard/iPadShowKeyboard.js'; |
||||
import "monaco-editor/esm/vs/editor/editor.api.js"; |
||||
|
||||
// (2) Desired languages:
|
||||
import '@alexanderzobnin/monaco-kusto/release/webpack/bridge.min.js'; |
||||
import '@alexanderzobnin/monaco-kusto/release/webpack/Kusto.JavaScript.Client.min.js'; |
||||
import '@alexanderzobnin/monaco-kusto/release/webpack/Kusto.Language.Bridge.min.js'; |
||||
import '@alexanderzobnin/monaco-kusto/release/webpack/monaco.contribution.min.js'; |
||||
@ -1,122 +0,0 @@ |
||||
const path = require('path'); |
||||
const webpack = require('webpack'); |
||||
const UglifyJSPlugin = require('uglifyjs-webpack-plugin'); |
||||
|
||||
module.exports = { |
||||
// output: {
|
||||
// filename: 'monaco.min.js',
|
||||
// path: path.resolve(__dirname, 'dist'),
|
||||
// libraryTarget: 'umd',
|
||||
// library: 'monaco',
|
||||
// globalObject: 'self'
|
||||
// },
|
||||
entry: { |
||||
// monaco: './public/app/plugins/datasource/grafana-azure-monitor-datasource/monaco/monaco-loader.ts',
|
||||
}, |
||||
output: { |
||||
// filename: 'monaco.min.js',
|
||||
// chunkFilename: '[name].bundle.js',
|
||||
globalObject: 'self', |
||||
}, |
||||
resolveLoader: { |
||||
alias: { |
||||
'blob-url-loader': require.resolve('./loaders/blobUrl'), |
||||
'compile-loader': require.resolve('./loaders/compile'), |
||||
}, |
||||
}, |
||||
module: { |
||||
rules: [ |
||||
{ |
||||
test: /\.css$/, |
||||
use: [ 'style-loader', 'css-loader' ] |
||||
}, |
||||
// {
|
||||
// // https://github.com/bridgedotnet/Bridge/issues/3097
|
||||
// test: /bridge\.js$/,
|
||||
// loader: 'regexp-replace-loader',
|
||||
// options: {
|
||||
// match: {
|
||||
// pattern: "globals\\.System\\s=\\s\\{\\};"
|
||||
// },
|
||||
// replaceWith: "$& System = globals.System; "
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// test: /Kusto\.JavaScript\.Client\.js$/,
|
||||
// loader: 'regexp-replace-loader',
|
||||
// options: {
|
||||
// match: {
|
||||
// pattern: '"use strict";'
|
||||
// },
|
||||
// replaceWith: "$& System = globals.System; "
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// test: /Kusto\.Language\.Bridge\.js$/,
|
||||
// loader: 'regexp-replace-loader',
|
||||
// options: {
|
||||
// match: {
|
||||
// pattern: '"use strict";'
|
||||
// },
|
||||
// replaceWith: "$& System = globals.System; "
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// test: /newtonsoft\.json\.js$/,
|
||||
// loader: 'regexp-replace-loader',
|
||||
// options: {
|
||||
// match: {
|
||||
// pattern: '"use strict";'
|
||||
// },
|
||||
// replaceWith: "$& System = globals.System; "
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// test: /monaco\.contribution\.js$/,
|
||||
// loader: 'regexp-replace-loader',
|
||||
// options: {
|
||||
// match: {
|
||||
// pattern: 'vs/language/kusto/kustoMode',
|
||||
// flags: 'g'
|
||||
// },
|
||||
// replaceWith: "./kustoMode"
|
||||
// }
|
||||
// },
|
||||
] |
||||
}, |
||||
optimization: { |
||||
splitChunks: { |
||||
// chunks: 'all',
|
||||
cacheGroups: { |
||||
// monacoContribution: {
|
||||
// test: /(src)|(node_modules(?!\/@kusto))/,
|
||||
// name: 'monaco.contribution',
|
||||
// enforce: false,
|
||||
// // chunks: 'all',
|
||||
// },
|
||||
// bridge: {
|
||||
// test: /bridge/,
|
||||
// name: 'bridge',
|
||||
// chunks: 'all',
|
||||
// },
|
||||
// KustoJavaScriptClient: {
|
||||
// test: /Kusto\.JavaScript\.Client/,
|
||||
// name: 'kusto.javaScript.client',
|
||||
// chunks: 'all',
|
||||
// },
|
||||
// KustoLanguageBridge: {
|
||||
// test: /Kusto\.Language\.Bridge/,
|
||||
// name: 'kusto.language.bridge',
|
||||
// chunks: 'all',
|
||||
// },
|
||||
} |
||||
} |
||||
}, |
||||
plugins: [ |
||||
new webpack.IgnorePlugin(/^((fs)|(path)|(os)|(crypto)|(source-map-support))$/, /vs\/language\/typescript\/lib/), |
||||
// new webpack.optimize.LimitChunkCountPlugin({
|
||||
// maxChunks: 1,
|
||||
// }),
|
||||
// new UglifyJSPlugin()
|
||||
], |
||||
}; |
||||
Loading…
Reference in new issue