query_field.tsx 9.0 KB

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