KustoQueryField.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. import Plain from 'slate-plain-serializer';
  2. import QueryField from './query_field';
  3. // import debounce from './utils/debounce';
  4. // import {getNextCharacter} from './utils/dom';
  5. import debounce from 'app/features/explore/utils/debounce';
  6. import { getNextCharacter } from 'app/features/explore/utils/dom';
  7. import { FUNCTIONS, KEYWORDS } from './kusto';
  8. // import '../sass/editor.base.scss';
  9. const TYPEAHEAD_DELAY = 500;
  10. interface Suggestion {
  11. text: string;
  12. deleteBackwards?: number;
  13. type?: string;
  14. }
  15. interface SuggestionGroup {
  16. label: string;
  17. items: Suggestion[];
  18. prefixMatch?: boolean;
  19. skipFilter?: boolean;
  20. }
  21. const cleanText = s => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
  22. const wrapText = text => ({ text });
  23. export default class KustoQueryField extends QueryField {
  24. fields: any;
  25. events: any;
  26. constructor(props, context) {
  27. super(props, context);
  28. this.onTypeahead = debounce(this.onTypeahead, TYPEAHEAD_DELAY);
  29. }
  30. componentDidMount() {
  31. this.updateMenu();
  32. }
  33. onTypeahead = () => {
  34. const selection = window.getSelection();
  35. if (selection.anchorNode) {
  36. const wrapperNode = selection.anchorNode.parentElement;
  37. if (wrapperNode === null) {
  38. return;
  39. }
  40. const editorNode = wrapperNode.closest('.slate-query-field');
  41. if (!editorNode || this.state.value.isBlurred) {
  42. // Not inside this editor
  43. return;
  44. }
  45. // DOM ranges
  46. const range = selection.getRangeAt(0);
  47. const text = selection.anchorNode.textContent;
  48. if (text === null) {
  49. return;
  50. }
  51. const offset = range.startOffset;
  52. let prefix = cleanText(text.substr(0, offset));
  53. // Model ranges
  54. const modelOffset = this.state.value.anchorOffset;
  55. const modelPrefix = this.state.value.anchorText.text.slice(0, modelOffset);
  56. // Determine candidates by context
  57. let suggestionGroups: SuggestionGroup[] = [];
  58. const wrapperClasses = wrapperNode.classList;
  59. let typeaheadContext: string | null = null;
  60. if (wrapperClasses.contains('function-context')) {
  61. typeaheadContext = 'context-function';
  62. if (this.fields) {
  63. suggestionGroups = this._getFieldsSuggestions();
  64. } else {
  65. this._fetchFields();
  66. return;
  67. }
  68. } else if (modelPrefix.match(/(facet\s$)/i)) {
  69. typeaheadContext = 'context-facet';
  70. if (this.fields) {
  71. suggestionGroups = this._getFieldsSuggestions();
  72. } else {
  73. this._fetchFields();
  74. return;
  75. }
  76. } else if (modelPrefix.match(/(,\s*$)/)) {
  77. typeaheadContext = 'context-multiple-fields';
  78. if (this.fields) {
  79. suggestionGroups = this._getFieldsSuggestions();
  80. } else {
  81. this._fetchFields();
  82. return;
  83. }
  84. } else if (modelPrefix.match(/(from\s$)/i)) {
  85. typeaheadContext = 'context-from';
  86. if (this.events) {
  87. suggestionGroups = this._getAfterFromSuggestions();
  88. } else {
  89. this._fetchEvents();
  90. return;
  91. }
  92. } else if (modelPrefix.match(/(^select\s\w*$)/i)) {
  93. typeaheadContext = 'context-select';
  94. if (this.fields) {
  95. suggestionGroups = this._getAfterSelectSuggestions();
  96. } else {
  97. this._fetchFields();
  98. return;
  99. }
  100. } else if (
  101. modelPrefix.match(/\)\s$/) ||
  102. modelPrefix.match(/SELECT ((?:\$?\w+\(?\w*\)?\,?\s*)+)([\)]\s|\b)/gi)
  103. ) {
  104. typeaheadContext = 'context-after-function';
  105. suggestionGroups = this._getAfterFunctionSuggestions();
  106. } else if (modelPrefix.match(/from\s\S+\s\w*$/i)) {
  107. prefix = '';
  108. typeaheadContext = 'context-since';
  109. suggestionGroups = this._getAfterEventSuggestions();
  110. // } else if (modelPrefix.match(/\d+\s\w*$/)) {
  111. // typeaheadContext = 'context-number';
  112. // suggestionGroups = this._getAfterNumberSuggestions();
  113. } else if (modelPrefix.match(/ago\b/i) || modelPrefix.match(/facet\b/i) || modelPrefix.match(/\$__timefilter\b/i)) {
  114. typeaheadContext = 'context-timeseries';
  115. suggestionGroups = this._getAfterAgoSuggestions();
  116. } else if (prefix && !wrapperClasses.contains('argument')) {
  117. typeaheadContext = 'context-builtin';
  118. suggestionGroups = this._getKeywordSuggestions();
  119. } else if (Plain.serialize(this.state.value) === '') {
  120. typeaheadContext = 'context-new';
  121. suggestionGroups = this._getInitialSuggestions();
  122. }
  123. let results = 0;
  124. prefix = prefix.toLowerCase();
  125. const filteredSuggestions = suggestionGroups.map(group => {
  126. if (group.items && prefix && !group.skipFilter) {
  127. group.items = group.items.filter(c => c.text.length >= prefix.length);
  128. if (group.prefixMatch) {
  129. group.items = group.items.filter(c => c.text.toLowerCase().indexOf(prefix) === 0);
  130. } else {
  131. group.items = group.items.filter(c => c.text.toLowerCase().indexOf(prefix) > -1);
  132. }
  133. }
  134. results += group.items.length;
  135. return group;
  136. })
  137. .filter(group => group.items.length > 0);
  138. // console.log('onTypeahead', selection.anchorNode, wrapperClasses, text, offset, prefix, typeaheadContext);
  139. this.setState({
  140. typeaheadPrefix: prefix,
  141. typeaheadContext,
  142. typeaheadText: text,
  143. suggestions: results > 0 ? filteredSuggestions : [],
  144. });
  145. }
  146. }
  147. applyTypeahead(change, suggestion) {
  148. const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state;
  149. let suggestionText = suggestion.text || suggestion;
  150. const move = 0;
  151. // Modify suggestion based on context
  152. const nextChar = getNextCharacter();
  153. if (suggestion.type === 'function') {
  154. if (!nextChar || nextChar !== '(') {
  155. suggestionText += '(';
  156. }
  157. } else if (typeaheadContext === 'context-function') {
  158. if (!nextChar || nextChar !== ')') {
  159. suggestionText += ')';
  160. }
  161. } else {
  162. if (!nextChar || nextChar !== ' ') {
  163. suggestionText += ' ';
  164. }
  165. }
  166. this.resetTypeahead();
  167. // Remove the current, incomplete text and replace it with the selected suggestion
  168. const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
  169. const text = cleanText(typeaheadText);
  170. const suffixLength = text.length - typeaheadPrefix.length;
  171. const offset = typeaheadText.indexOf(typeaheadPrefix);
  172. const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
  173. const forward = midWord ? suffixLength + offset : 0;
  174. return change
  175. .deleteBackward(backward)
  176. .deleteForward(forward)
  177. .insertText(suggestionText)
  178. .move(move)
  179. .focus();
  180. }
  181. private _getFieldsSuggestions(): SuggestionGroup[] {
  182. return [
  183. {
  184. prefixMatch: true,
  185. label: 'Fields',
  186. items: this.fields.map(wrapText)
  187. },
  188. {
  189. prefixMatch: true,
  190. label: 'Variables',
  191. items: this.props.templateVariables.map(wrapText)
  192. }
  193. ];
  194. }
  195. private _getAfterFromSuggestions(): SuggestionGroup[] {
  196. return [
  197. {
  198. skipFilter: true,
  199. label: 'Events',
  200. items: this.events.map(wrapText)
  201. },
  202. {
  203. prefixMatch: true,
  204. label: 'Variables',
  205. items: this.props.templateVariables
  206. .map(wrapText)
  207. .map(suggestion => {
  208. suggestion.deleteBackwards = 0;
  209. return suggestion;
  210. })
  211. }
  212. ];
  213. }
  214. private _getAfterSelectSuggestions(): SuggestionGroup[] {
  215. return [
  216. {
  217. prefixMatch: true,
  218. label: 'Fields',
  219. items: this.fields.map(wrapText)
  220. },
  221. {
  222. prefixMatch: true,
  223. label: 'Functions',
  224. items: FUNCTIONS.map((s: any) => { s.type = 'function'; return s; })
  225. },
  226. {
  227. prefixMatch: true,
  228. label: 'Variables',
  229. items: this.props.templateVariables.map(wrapText)
  230. }
  231. ];
  232. }
  233. private _getAfterFunctionSuggestions(): SuggestionGroup[] {
  234. return [{
  235. prefixMatch: true,
  236. label: 'Keywords',
  237. items: ['FROM'].map(wrapText)
  238. }];
  239. }
  240. private _getAfterEventSuggestions(): SuggestionGroup[] {
  241. return [
  242. {
  243. skipFilter: true,
  244. label: 'Keywords',
  245. items: ['SINCE'].map(wrapText)
  246. .map((suggestion: any) => {
  247. suggestion.deleteBackwards = 0;
  248. return suggestion;
  249. })
  250. },
  251. {
  252. skipFilter: true,
  253. label: 'Macros',
  254. items: ['$__timeFilter'].map(wrapText)
  255. .map((suggestion: any) => {
  256. suggestion.deleteBackwards = 0;
  257. return suggestion;
  258. })
  259. }
  260. ];
  261. }
  262. // private _getAfterNumberSuggestions(): SuggestionGroup[] {
  263. // return [{
  264. // prefixMatch: true,
  265. // label: 'Duration',
  266. // items: DURATION
  267. // .map(d => `${d} AGO`)
  268. // .map(wrapText)
  269. // }];
  270. // }
  271. private _getAfterAgoSuggestions(): SuggestionGroup[] {
  272. return [{
  273. prefixMatch: true,
  274. label: 'Keywords',
  275. items: ['TIMESERIES', 'COMPARE WITH', 'FACET'].map(wrapText)
  276. }];
  277. }
  278. private _getKeywordSuggestions(): SuggestionGroup[] {
  279. return [{
  280. prefixMatch: true,
  281. label: 'Keywords',
  282. items: KEYWORDS.map(wrapText)
  283. }];
  284. }
  285. private _getInitialSuggestions(): SuggestionGroup[] {
  286. // TODO: return datbase tables as an initial suggestion
  287. return [{
  288. prefixMatch: true,
  289. label: 'Keywords',
  290. items: KEYWORDS.map(wrapText)
  291. }];
  292. }
  293. private async _fetchEvents() {
  294. const query = 'events';
  295. const result = await this.request(query);
  296. if (result === undefined) {
  297. this.events = [];
  298. } else {
  299. this.events = result;
  300. }
  301. setTimeout(this.onTypeahead, 0);
  302. }
  303. private async _fetchFields() {
  304. const query = 'fields';
  305. const result = await this.request(query);
  306. this.fields = result || [];
  307. setTimeout(this.onTypeahead, 0);
  308. }
  309. }