import _ from 'lodash'; 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 { KEYWORDS, functionTokens, operatorTokens, grafanaMacros } from './kusto/kusto'; // import '../sass/editor.base.scss'; const TYPEAHEAD_DELAY = 100; interface Suggestion { text: string; deleteBackwards?: number; type?: string; } interface SuggestionGroup { label: string; items: Suggestion[]; prefixMatch?: boolean; skipFilter?: boolean; } interface KustoSchema { Databases: { Default?: KustoDBSchema; }; Plugins?: any[]; } interface KustoDBSchema { Name?: string; Functions?: any; Tables?: any; } const defaultSchema = () => ({ Databases: { Default: {} } }); const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim(); const wrapText = text => ({ text }); export default class KustoQueryField extends QueryField { fields: any; events: any; schema: KustoSchema; constructor(props, context) { super(props, context); this.schema = defaultSchema(); this.onTypeahead = debounce(this.onTypeahead, TYPEAHEAD_DELAY); } componentDidMount() { super.componentDidMount(); this.fetchSchema(); } 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._getKeywordSuggestions(); } else { this._fetchFields(); return; } } else if (modelPrefix.match(/(where\s$)/i)) { typeaheadContext = 'context-where'; const fullQuery = Plain.serialize(this.state.value); const table = this.getTableFromContext(fullQuery); if (table) { suggestionGroups = this.getWhereSuggestions(table); } else { return; } } else if (modelPrefix.match(/(,\s*$)/)) { typeaheadContext = 'context-multiple-fields'; if (this.fields) { suggestionGroups = this._getKeywordSuggestions(); } else { this._fetchFields(); return; } } else if (modelPrefix.match(/(from\s$)/i)) { typeaheadContext = 'context-from'; if (this.events) { suggestionGroups = this._getKeywordSuggestions(); } else { this._fetchEvents(); return; } } else if (modelPrefix.match(/(^select\s\w*$)/i)) { typeaheadContext = 'context-select'; if (this.fields) { suggestionGroups = this._getKeywordSuggestions(); } else { this._fetchFields(); return; } } else if (modelPrefix.match(/from\s\S+\s\w*$/i)) { prefix = ''; typeaheadContext = 'context-since'; suggestionGroups = this._getKeywordSuggestions(); // } 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._getKeywordSuggestions(); } else if (prefix && !wrapperClasses.contains('argument')) { typeaheadContext = 'context-builtin'; suggestionGroups = this._getKeywordSuggestions(); } else if (Plain.serialize(this.state.value) === '') { typeaheadContext = 'context-new'; if (this.schema) { suggestionGroups = this._getInitialSuggestions(); } else { this.fetchSchema(); setTimeout(this.onTypeahead, 0); return; } } 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 _getKeywordSuggestions(): SuggestionGroup[] { return [ { prefixMatch: true, label: 'Keywords', items: KEYWORDS.map(wrapText) }, { prefixMatch: true, label: 'Operators', items: operatorTokens }, { prefixMatch: true, label: 'Functions', items: functionTokens.map((s: any) => { s.type = 'function'; return s; }) }, { prefixMatch: true, label: 'Macros', items: grafanaMacros.map((s: any) => { s.type = 'function'; return s; }) }, { prefixMatch: true, label: 'Tables', items: _.map(this.schema.Databases.Default.Tables, (t: any) => ({ text: t.Name })) } ]; } private _getInitialSuggestions(): SuggestionGroup[] { return [ { prefixMatch: true, label: 'Tables', items: _.map(this.schema.Databases.Default.Tables, (t: any) => ({ text: t.Name })) } ]; // return [ // { // prefixMatch: true, // label: 'Keywords', // items: KEYWORDS.map(wrapText) // }, // { // prefixMatch: true, // label: 'Operators', // items: operatorTokens.map((s: any) => { s.type = 'function'; return s; }) // }, // { // prefixMatch: true, // label: 'Functions', // items: functionTokens.map((s: any) => { s.type = 'function'; return s; }) // }, // { // prefixMatch: true, // label: 'Macros', // items: grafanaMacros.map((s: any) => { s.type = 'function'; return s; }) // } // ]; } private getWhereSuggestions(table: string): SuggestionGroup[] { const tableSchema = this.schema.Databases.Default.Tables[table]; if (tableSchema) { return [ { prefixMatch: true, label: 'Fields', items: _.map(tableSchema.OrderedColumns, (f: any) => ({ text: f.Name, hint: f.Type })) } ]; } else { return []; } } private getTableFromContext(query: string) { const tablePattern = /^\s*(\w+)\s*|/g; const normalizedQuery = normalizeQuery(query); const match = tablePattern.exec(normalizedQuery); if (match && match.length > 1 && match[0] && match[1]) { return match[1]; } else { return null; } } 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); //Stub this.events = []; } private async _fetchFields() { // const query = 'fields'; // const result = await this.request(query); // this.fields = result || []; // setTimeout(this.onTypeahead, 0); // Stub this.fields = []; } private async fetchSchema() { const schema = await this.props.getSchema(); if (schema) { this.schema = schema; } else { this.schema = defaultSchema(); } } } function normalizeQuery(query: string): string { const commentPattern = /\/\/.*$/gm; let normalizedQuery = query.replace(commentPattern, ''); normalizedQuery = normalizedQuery.replace('\n', ' '); return normalizedQuery; }