query_field.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. import PluginPrism from 'app/features/explore/slate-plugins/prism';
  2. import BracesPlugin from 'app/features/explore/slate-plugins/braces';
  3. import ClearPlugin from 'app/features/explore/slate-plugins/clear';
  4. import NewlinePlugin from 'app/features/explore/slate-plugins/newline';
  5. import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
  6. import Typeahead from './typeahead';
  7. import { getKeybindingSrv, KeybindingSrv } from 'app/core/services/keybindingSrv';
  8. import { Block, Document, Text, Value } from 'slate';
  9. // @ts-ignore
  10. import { Editor } from 'slate-react';
  11. // @ts-ignore
  12. import Plain from 'slate-plain-serializer';
  13. import ReactDOM from 'react-dom';
  14. import React from 'react';
  15. import _ from 'lodash';
  16. function flattenSuggestions(s: any) {
  17. return s ? s.reduce((acc: any, g: any) => acc.concat(g.items), []) : [];
  18. }
  19. export const makeFragment = (text: string) => {
  20. const lines = text.split('\n').map((line: any) =>
  21. Block.create({
  22. type: 'paragraph',
  23. nodes: [Text.create(line)],
  24. } as any)
  25. );
  26. const fragment = Document.create({
  27. nodes: lines,
  28. });
  29. return fragment;
  30. };
  31. export const getInitialValue = (query: string) => Value.create({ document: makeFragment(query) });
  32. class Portal extends React.Component<any, any> {
  33. node: any;
  34. constructor(props: any) {
  35. super(props);
  36. const { index = 0, prefix = 'query' } = props;
  37. this.node = document.createElement('div');
  38. this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
  39. document.body.appendChild(this.node);
  40. }
  41. componentWillUnmount() {
  42. document.body.removeChild(this.node);
  43. }
  44. render() {
  45. return ReactDOM.createPortal(this.props.children, this.node);
  46. }
  47. }
  48. class QueryField extends React.Component<any, any> {
  49. menuEl: any;
  50. plugins: any;
  51. resetTimer: any;
  52. keybindingSrv: KeybindingSrv = getKeybindingSrv();
  53. constructor(props: any, context: any) {
  54. super(props, context);
  55. const { prismDefinition = {}, prismLanguage = 'kusto' } = props;
  56. this.plugins = [
  57. BracesPlugin(),
  58. ClearPlugin(),
  59. RunnerPlugin({ handler: props.onPressEnter }),
  60. NewlinePlugin(),
  61. PluginPrism({ definition: prismDefinition, language: prismLanguage }),
  62. ];
  63. this.state = {
  64. labelKeys: {},
  65. labelValues: {},
  66. suggestions: [],
  67. typeaheadIndex: 0,
  68. typeaheadPrefix: '',
  69. value: getInitialValue(props.initialQuery || ''),
  70. };
  71. }
  72. componentDidMount() {
  73. this.updateMenu();
  74. }
  75. componentWillUnmount() {
  76. this.restoreEscapeKeyBinding();
  77. clearTimeout(this.resetTimer);
  78. }
  79. componentDidUpdate() {
  80. this.updateMenu();
  81. }
  82. onChange = ({ value }: any) => {
  83. const changed = value.document !== this.state.value.document;
  84. this.setState({ value }, () => {
  85. if (changed) {
  86. // call typeahead only if query changed
  87. requestAnimationFrame(() => this.onTypeahead());
  88. this.onChangeQuery();
  89. }
  90. });
  91. };
  92. request = (url?: string) => {
  93. if (this.props.request) {
  94. return this.props.request(url);
  95. }
  96. return fetch(url);
  97. };
  98. onChangeQuery = () => {
  99. // Send text change to parent
  100. const { onQueryChange } = this.props;
  101. if (onQueryChange) {
  102. onQueryChange(Plain.serialize(this.state.value));
  103. }
  104. };
  105. onKeyDown = (event: any, change: any) => {
  106. const { typeaheadIndex, suggestions } = this.state;
  107. switch (event.key) {
  108. case 'Escape': {
  109. if (this.menuEl) {
  110. event.preventDefault();
  111. event.stopPropagation();
  112. this.resetTypeahead();
  113. return true;
  114. }
  115. break;
  116. }
  117. case ' ': {
  118. if (event.ctrlKey) {
  119. event.preventDefault();
  120. this.onTypeahead(true);
  121. return true;
  122. }
  123. break;
  124. }
  125. case 'Tab':
  126. case 'Enter': {
  127. if (this.menuEl) {
  128. // Dont blur input
  129. event.preventDefault();
  130. if (!suggestions || suggestions.length === 0) {
  131. return undefined;
  132. }
  133. // Get the currently selected suggestion
  134. const flattenedSuggestions = flattenSuggestions(suggestions);
  135. const selected = Math.abs(typeaheadIndex);
  136. const selectedIndex = selected % flattenedSuggestions.length || 0;
  137. const suggestion = flattenedSuggestions[selectedIndex];
  138. this.applyTypeahead(change, suggestion);
  139. return true;
  140. }
  141. break;
  142. }
  143. case 'ArrowDown': {
  144. if (this.menuEl) {
  145. // Select next suggestion
  146. event.preventDefault();
  147. this.setState({ typeaheadIndex: typeaheadIndex + 1 });
  148. }
  149. break;
  150. }
  151. case 'ArrowUp': {
  152. if (this.menuEl) {
  153. // Select previous suggestion
  154. event.preventDefault();
  155. this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) });
  156. }
  157. break;
  158. }
  159. default: {
  160. // console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
  161. break;
  162. }
  163. }
  164. return undefined;
  165. };
  166. onTypeahead = (change?: boolean, item?: any) => {
  167. return change || this.state.value.change();
  168. };
  169. applyTypeahead(change?: boolean, suggestion?: any): { value: object } {
  170. return { value: {} };
  171. }
  172. resetTypeahead = () => {
  173. this.setState({
  174. suggestions: [],
  175. typeaheadIndex: 0,
  176. typeaheadPrefix: '',
  177. typeaheadContext: null,
  178. });
  179. };
  180. handleBlur = () => {
  181. const { onBlur } = this.props;
  182. // If we dont wait here, menu clicks wont work because the menu
  183. // will be gone.
  184. this.resetTimer = setTimeout(this.resetTypeahead, 100);
  185. if (onBlur) {
  186. onBlur();
  187. }
  188. this.restoreEscapeKeyBinding();
  189. };
  190. handleFocus = () => {
  191. const { onFocus } = this.props;
  192. if (onFocus) {
  193. onFocus();
  194. }
  195. // Don't go back to dashboard if Escape pressed inside the editor.
  196. this.removeEscapeKeyBinding();
  197. };
  198. removeEscapeKeyBinding() {
  199. this.keybindingSrv.unbind('esc', 'keydown');
  200. }
  201. restoreEscapeKeyBinding() {
  202. this.keybindingSrv.setupGlobal();
  203. }
  204. onClickItem = (item: any) => {
  205. const { suggestions } = this.state;
  206. if (!suggestions || suggestions.length === 0) {
  207. return;
  208. }
  209. // Get the currently selected suggestion
  210. const flattenedSuggestions = flattenSuggestions(suggestions);
  211. const suggestion: any = _.find(
  212. flattenedSuggestions,
  213. suggestion => suggestion.display === item || suggestion.text === item
  214. );
  215. // Manually triggering change
  216. const change = this.applyTypeahead(this.state.value.change(), suggestion);
  217. this.onChange(change);
  218. };
  219. updateMenu = () => {
  220. const { suggestions } = this.state;
  221. const menu = this.menuEl;
  222. const selection = window.getSelection();
  223. const node = selection.anchorNode;
  224. // No menu, nothing to do
  225. if (!menu) {
  226. return;
  227. }
  228. // No suggestions or blur, remove menu
  229. const hasSuggesstions = suggestions && suggestions.length > 0;
  230. if (!hasSuggesstions) {
  231. menu.removeAttribute('style');
  232. return;
  233. }
  234. // Align menu overlay to editor node
  235. if (node && node.parentElement) {
  236. // Read from DOM
  237. const rect = node.parentElement.getBoundingClientRect();
  238. const scrollX = window.scrollX;
  239. const scrollY = window.scrollY;
  240. const screenHeight = window.innerHeight;
  241. const menuLeft = rect.left + scrollX - 2;
  242. const menuTop = rect.top + scrollY + rect.height + 4;
  243. const menuHeight = screenHeight - menuTop - 10;
  244. // Write DOM
  245. requestAnimationFrame(() => {
  246. menu.style.opacity = 1;
  247. menu.style.top = `${menuTop}px`;
  248. menu.style.left = `${menuLeft}px`;
  249. menu.style.maxHeight = `${menuHeight}px`;
  250. });
  251. }
  252. };
  253. menuRef = (el: any) => {
  254. this.menuEl = el;
  255. };
  256. renderMenu = () => {
  257. const { portalPrefix } = this.props;
  258. const { suggestions } = this.state;
  259. const hasSuggesstions = suggestions && suggestions.length > 0;
  260. if (!hasSuggesstions) {
  261. return null;
  262. }
  263. // Guard selectedIndex to be within the length of the suggestions
  264. let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
  265. const flattenedSuggestions = flattenSuggestions(suggestions);
  266. selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
  267. const selectedKeys = (flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : []).map(i =>
  268. typeof i === 'object' ? i.text : i
  269. );
  270. // Create typeahead in DOM root so we can later position it absolutely
  271. return (
  272. <Portal prefix={portalPrefix}>
  273. <Typeahead
  274. menuRef={this.menuRef}
  275. selectedItems={selectedKeys}
  276. onClickItem={this.onClickItem}
  277. groupedItems={suggestions}
  278. />
  279. </Portal>
  280. );
  281. };
  282. render() {
  283. return (
  284. <div className="slate-query-field">
  285. {this.renderMenu()}
  286. <Editor
  287. autoCorrect={false}
  288. onBlur={this.handleBlur}
  289. onKeyDown={this.onKeyDown}
  290. onChange={this.onChange}
  291. onFocus={this.handleFocus}
  292. placeholder={this.props.placeholder}
  293. plugins={this.plugins}
  294. spellCheck={false}
  295. value={this.state.value}
  296. />
  297. </div>
  298. );
  299. }
  300. }
  301. export default QueryField;