QueryField.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. import React from 'react';
  2. import ReactDOM from 'react-dom';
  3. import { Value } from 'slate';
  4. import { Editor } from 'slate-react';
  5. import Plain from 'slate-plain-serializer';
  6. // dom also includes Element polyfills
  7. import { getNextCharacter, getPreviousCousin } from './utils/dom';
  8. import BracesPlugin from './slate-plugins/braces';
  9. import ClearPlugin from './slate-plugins/clear';
  10. import NewlinePlugin from './slate-plugins/newline';
  11. import PluginPrism, { setPrismTokens } from './slate-plugins/prism/index';
  12. import RunnerPlugin from './slate-plugins/runner';
  13. import debounce from './utils/debounce';
  14. import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus';
  15. import Typeahead from './Typeahead';
  16. const EMPTY_METRIC = '';
  17. export const TYPEAHEAD_DEBOUNCE = 300;
  18. function flattenSuggestions(s) {
  19. return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
  20. }
  21. export const getInitialValue = query =>
  22. Value.fromJSON({
  23. document: {
  24. nodes: [
  25. {
  26. object: 'block',
  27. type: 'paragraph',
  28. nodes: [
  29. {
  30. object: 'text',
  31. leaves: [
  32. {
  33. text: query,
  34. },
  35. ],
  36. },
  37. ],
  38. },
  39. ],
  40. },
  41. });
  42. class Portal extends React.Component<any, any> {
  43. node: any;
  44. constructor(props) {
  45. super(props);
  46. const { index = 0, prefix = 'query' } = props;
  47. this.node = document.createElement('div');
  48. this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
  49. document.body.appendChild(this.node);
  50. }
  51. componentWillUnmount() {
  52. document.body.removeChild(this.node);
  53. }
  54. render() {
  55. return ReactDOM.createPortal(this.props.children, this.node);
  56. }
  57. }
  58. class QueryField extends React.Component<any, any> {
  59. menuEl: any;
  60. plugins: any;
  61. resetTimer: any;
  62. constructor(props, context) {
  63. super(props, context);
  64. const { prismDefinition = {}, prismLanguage = 'promql' } = props;
  65. this.plugins = [
  66. BracesPlugin(),
  67. ClearPlugin(),
  68. RunnerPlugin({ handler: props.onPressEnter }),
  69. NewlinePlugin(),
  70. PluginPrism({ definition: prismDefinition, language: prismLanguage }),
  71. ];
  72. this.state = {
  73. labelKeys: {},
  74. labelValues: {},
  75. metrics: props.metrics || [],
  76. suggestions: [],
  77. typeaheadIndex: 0,
  78. typeaheadPrefix: '',
  79. value: getInitialValue(props.initialQuery || ''),
  80. };
  81. }
  82. componentDidMount() {
  83. this.updateMenu();
  84. if (this.props.metrics === undefined) {
  85. this.fetchMetricNames();
  86. }
  87. }
  88. componentWillUnmount() {
  89. clearTimeout(this.resetTimer);
  90. }
  91. componentDidUpdate() {
  92. this.updateMenu();
  93. }
  94. componentWillReceiveProps(nextProps) {
  95. if (nextProps.metrics && nextProps.metrics !== this.props.metrics) {
  96. this.setState({ metrics: nextProps.metrics }, this.onMetricsReceived);
  97. }
  98. // initialQuery is null in case the user typed
  99. if (nextProps.initialQuery !== null && nextProps.initialQuery !== this.props.initialQuery) {
  100. this.setState({ value: getInitialValue(nextProps.initialQuery) });
  101. }
  102. }
  103. onChange = ({ value }) => {
  104. const changed = value.document !== this.state.value.document;
  105. this.setState({ value }, () => {
  106. if (changed) {
  107. this.handleChangeQuery();
  108. }
  109. });
  110. window.requestAnimationFrame(this.handleTypeahead);
  111. };
  112. onMetricsReceived = () => {
  113. if (!this.state.metrics) {
  114. return;
  115. }
  116. setPrismTokens(this.props.prismLanguage, 'metrics', this.state.metrics);
  117. // Trigger re-render
  118. window.requestAnimationFrame(() => {
  119. // Bogus edit to trigger highlighting
  120. const change = this.state.value
  121. .change()
  122. .insertText(' ')
  123. .deleteBackward(1);
  124. this.onChange(change);
  125. });
  126. };
  127. request = url => {
  128. if (this.props.request) {
  129. return this.props.request(url);
  130. }
  131. return fetch(url);
  132. };
  133. handleChangeQuery = () => {
  134. // Send text change to parent
  135. const { onQueryChange } = this.props;
  136. if (onQueryChange) {
  137. onQueryChange(Plain.serialize(this.state.value));
  138. }
  139. };
  140. handleTypeahead = debounce(() => {
  141. const selection = window.getSelection();
  142. if (selection.anchorNode) {
  143. const wrapperNode = selection.anchorNode.parentElement;
  144. const editorNode = wrapperNode.closest('.slate-query-field');
  145. if (!editorNode || this.state.value.isBlurred) {
  146. // Not inside this editor
  147. return;
  148. }
  149. const range = selection.getRangeAt(0);
  150. const text = selection.anchorNode.textContent;
  151. const offset = range.startOffset;
  152. const prefix = cleanText(text.substr(0, offset));
  153. // Determine candidates by context
  154. const suggestionGroups = [];
  155. const wrapperClasses = wrapperNode.classList;
  156. let typeaheadContext = null;
  157. // Take first metric as lucky guess
  158. const metricNode = editorNode.querySelector('.metric');
  159. if (wrapperClasses.contains('context-range')) {
  160. // Rate ranges
  161. typeaheadContext = 'context-range';
  162. suggestionGroups.push({
  163. label: 'Range vector',
  164. items: [...RATE_RANGES],
  165. });
  166. } else if (wrapperClasses.contains('context-labels') && metricNode) {
  167. const metric = metricNode.textContent;
  168. const labelKeys = this.state.labelKeys[metric];
  169. if (labelKeys) {
  170. if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
  171. // Label values
  172. const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
  173. if (labelKeyNode) {
  174. const labelKey = labelKeyNode.textContent;
  175. const labelValues = this.state.labelValues[metric][labelKey];
  176. typeaheadContext = 'context-label-values';
  177. suggestionGroups.push({
  178. label: 'Label values',
  179. items: labelValues,
  180. });
  181. }
  182. } else {
  183. // Label keys
  184. typeaheadContext = 'context-labels';
  185. suggestionGroups.push({ label: 'Labels', items: labelKeys });
  186. }
  187. } else {
  188. this.fetchMetricLabels(metric);
  189. }
  190. } else if (wrapperClasses.contains('context-labels') && !metricNode) {
  191. // Empty name queries
  192. const defaultKeys = ['job', 'instance'];
  193. // Munge all keys that we have seen together
  194. const labelKeys = Object.keys(this.state.labelKeys).reduce((acc, metric) => {
  195. return acc.concat(this.state.labelKeys[metric].filter(key => acc.indexOf(key) === -1));
  196. }, defaultKeys);
  197. if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
  198. // Label values
  199. const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
  200. if (labelKeyNode) {
  201. const labelKey = labelKeyNode.textContent;
  202. if (this.state.labelValues[EMPTY_METRIC]) {
  203. const labelValues = this.state.labelValues[EMPTY_METRIC][labelKey];
  204. typeaheadContext = 'context-label-values';
  205. suggestionGroups.push({
  206. label: 'Label values',
  207. items: labelValues,
  208. });
  209. } else {
  210. // Can only query label values for now (API to query keys is under development)
  211. this.fetchLabelValues(labelKey);
  212. }
  213. }
  214. } else {
  215. // Label keys
  216. typeaheadContext = 'context-labels';
  217. suggestionGroups.push({ label: 'Labels', items: labelKeys });
  218. }
  219. } else if (metricNode && wrapperClasses.contains('context-aggregation')) {
  220. typeaheadContext = 'context-aggregation';
  221. const metric = metricNode.textContent;
  222. const labelKeys = this.state.labelKeys[metric];
  223. if (labelKeys) {
  224. suggestionGroups.push({ label: 'Labels', items: labelKeys });
  225. } else {
  226. this.fetchMetricLabels(metric);
  227. }
  228. } else if (
  229. (this.state.metrics && ((prefix && !wrapperClasses.contains('token')) || text.match(/[+\-*/^%]/))) ||
  230. wrapperClasses.contains('context-function')
  231. ) {
  232. // Need prefix for metrics
  233. typeaheadContext = 'context-metrics';
  234. suggestionGroups.push({
  235. label: 'Metrics',
  236. items: this.state.metrics,
  237. });
  238. }
  239. let results = 0;
  240. const filteredSuggestions = suggestionGroups.map(group => {
  241. if (group.items) {
  242. group.items = group.items.filter(c => c.length !== prefix.length && c.indexOf(prefix) > -1);
  243. results += group.items.length;
  244. }
  245. return group;
  246. });
  247. console.log('handleTypeahead', selection.anchorNode, wrapperClasses, text, offset, prefix, typeaheadContext);
  248. this.setState({
  249. typeaheadPrefix: prefix,
  250. typeaheadContext,
  251. typeaheadText: text,
  252. suggestions: results > 0 ? filteredSuggestions : [],
  253. });
  254. }
  255. }, TYPEAHEAD_DEBOUNCE);
  256. applyTypeahead(change, suggestion) {
  257. const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state;
  258. // Modify suggestion based on context
  259. switch (typeaheadContext) {
  260. case 'context-labels': {
  261. const nextChar = getNextCharacter();
  262. if (!nextChar || nextChar === '}' || nextChar === ',') {
  263. suggestion += '=';
  264. }
  265. break;
  266. }
  267. case 'context-label-values': {
  268. // Always add quotes and remove existing ones instead
  269. if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
  270. suggestion = `"${suggestion}`;
  271. }
  272. if (getNextCharacter() !== '"') {
  273. suggestion = `${suggestion}"`;
  274. }
  275. break;
  276. }
  277. default:
  278. }
  279. this.resetTypeahead();
  280. // Remove the current, incomplete text and replace it with the selected suggestion
  281. let backward = typeaheadPrefix.length;
  282. const text = cleanText(typeaheadText);
  283. const suffixLength = text.length - typeaheadPrefix.length;
  284. const offset = typeaheadText.indexOf(typeaheadPrefix);
  285. const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestion === typeaheadText);
  286. const forward = midWord ? suffixLength + offset : 0;
  287. return (
  288. change
  289. // TODO this line breaks if cursor was moved left and length is longer than whole prefix
  290. .deleteBackward(backward)
  291. .deleteForward(forward)
  292. .insertText(suggestion)
  293. .focus()
  294. );
  295. }
  296. onKeyDown = (event, change) => {
  297. const { typeaheadIndex, suggestions } = this.state;
  298. switch (event.key) {
  299. case 'Escape': {
  300. if (this.menuEl) {
  301. event.preventDefault();
  302. event.stopPropagation();
  303. this.resetTypeahead();
  304. return true;
  305. }
  306. break;
  307. }
  308. case ' ': {
  309. if (event.ctrlKey) {
  310. event.preventDefault();
  311. this.handleTypeahead();
  312. return true;
  313. }
  314. break;
  315. }
  316. case 'Tab': {
  317. if (this.menuEl) {
  318. // Dont blur input
  319. event.preventDefault();
  320. if (!suggestions || suggestions.length === 0) {
  321. return undefined;
  322. }
  323. // Get the currently selected suggestion
  324. const flattenedSuggestions = flattenSuggestions(suggestions);
  325. const selected = Math.abs(typeaheadIndex);
  326. const selectedIndex = selected % flattenedSuggestions.length || 0;
  327. const suggestion = flattenedSuggestions[selectedIndex];
  328. this.applyTypeahead(change, suggestion);
  329. return true;
  330. }
  331. break;
  332. }
  333. case 'ArrowDown': {
  334. if (this.menuEl) {
  335. // Select next suggestion
  336. event.preventDefault();
  337. this.setState({ typeaheadIndex: typeaheadIndex + 1 });
  338. }
  339. break;
  340. }
  341. case 'ArrowUp': {
  342. if (this.menuEl) {
  343. // Select previous suggestion
  344. event.preventDefault();
  345. this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) });
  346. }
  347. break;
  348. }
  349. default: {
  350. // console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
  351. break;
  352. }
  353. }
  354. return undefined;
  355. };
  356. resetTypeahead = () => {
  357. this.setState({
  358. suggestions: [],
  359. typeaheadIndex: 0,
  360. typeaheadPrefix: '',
  361. typeaheadContext: null,
  362. });
  363. };
  364. async fetchLabelValues(key) {
  365. const url = `/api/v1/label/${key}/values`;
  366. try {
  367. const res = await this.request(url);
  368. const body = await (res.data || res.json());
  369. const pairs = this.state.labelValues[EMPTY_METRIC];
  370. const values = {
  371. ...pairs,
  372. [key]: body.data,
  373. };
  374. // const labelKeys = {
  375. // ...this.state.labelKeys,
  376. // [EMPTY_METRIC]: keys,
  377. // };
  378. const labelValues = {
  379. ...this.state.labelValues,
  380. [EMPTY_METRIC]: values,
  381. };
  382. this.setState({ labelValues }, this.handleTypeahead);
  383. } catch (e) {
  384. if (this.props.onRequestError) {
  385. this.props.onRequestError(e);
  386. } else {
  387. console.error(e);
  388. }
  389. }
  390. }
  391. async fetchMetricLabels(name) {
  392. const url = `/api/v1/series?match[]=${name}`;
  393. try {
  394. const res = await this.request(url);
  395. const body = await (res.data || res.json());
  396. const { keys, values } = processLabels(body.data);
  397. const labelKeys = {
  398. ...this.state.labelKeys,
  399. [name]: keys,
  400. };
  401. const labelValues = {
  402. ...this.state.labelValues,
  403. [name]: values,
  404. };
  405. this.setState({ labelKeys, labelValues }, this.handleTypeahead);
  406. } catch (e) {
  407. if (this.props.onRequestError) {
  408. this.props.onRequestError(e);
  409. } else {
  410. console.error(e);
  411. }
  412. }
  413. }
  414. async fetchMetricNames() {
  415. const url = '/api/v1/label/__name__/values';
  416. try {
  417. const res = await this.request(url);
  418. const body = await (res.data || res.json());
  419. this.setState({ metrics: body.data }, this.onMetricsReceived);
  420. } catch (error) {
  421. if (this.props.onRequestError) {
  422. this.props.onRequestError(error);
  423. } else {
  424. console.error(error);
  425. }
  426. }
  427. }
  428. handleBlur = () => {
  429. const { onBlur } = this.props;
  430. // If we dont wait here, menu clicks wont work because the menu
  431. // will be gone.
  432. this.resetTimer = setTimeout(this.resetTypeahead, 100);
  433. if (onBlur) {
  434. onBlur();
  435. }
  436. };
  437. handleFocus = () => {
  438. const { onFocus } = this.props;
  439. if (onFocus) {
  440. onFocus();
  441. }
  442. };
  443. handleClickMenu = item => {
  444. // Manually triggering change
  445. const change = this.applyTypeahead(this.state.value.change(), item);
  446. this.onChange(change);
  447. };
  448. updateMenu = () => {
  449. const { suggestions } = this.state;
  450. const menu = this.menuEl;
  451. const selection = window.getSelection();
  452. const node = selection.anchorNode;
  453. // No menu, nothing to do
  454. if (!menu) {
  455. return;
  456. }
  457. // No suggestions or blur, remove menu
  458. const hasSuggesstions = suggestions && suggestions.length > 0;
  459. if (!hasSuggesstions) {
  460. menu.removeAttribute('style');
  461. return;
  462. }
  463. // Align menu overlay to editor node
  464. if (node) {
  465. // Read from DOM
  466. const rect = node.parentElement.getBoundingClientRect();
  467. const scrollX = window.scrollX;
  468. const scrollY = window.scrollY;
  469. // Write DOM
  470. requestAnimationFrame(() => {
  471. menu.style.opacity = 1;
  472. menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
  473. menu.style.left = `${rect.left + scrollX - 2}px`;
  474. });
  475. }
  476. };
  477. menuRef = el => {
  478. this.menuEl = el;
  479. };
  480. renderMenu = () => {
  481. const { portalPrefix } = this.props;
  482. const { suggestions } = this.state;
  483. const hasSuggesstions = suggestions && suggestions.length > 0;
  484. if (!hasSuggesstions) {
  485. return null;
  486. }
  487. // Guard selectedIndex to be within the length of the suggestions
  488. let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
  489. const flattenedSuggestions = flattenSuggestions(suggestions);
  490. selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
  491. const selectedKeys = (flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : []).map(
  492. i => (typeof i === 'object' ? i.text : i)
  493. );
  494. // Create typeahead in DOM root so we can later position it absolutely
  495. return (
  496. <Portal prefix={portalPrefix}>
  497. <Typeahead
  498. menuRef={this.menuRef}
  499. selectedItems={selectedKeys}
  500. onClickItem={this.handleClickMenu}
  501. groupedItems={suggestions}
  502. />
  503. </Portal>
  504. );
  505. };
  506. render() {
  507. return (
  508. <div className="slate-query-field">
  509. {this.renderMenu()}
  510. <Editor
  511. autoCorrect={false}
  512. onBlur={this.handleBlur}
  513. onKeyDown={this.onKeyDown}
  514. onChange={this.onChange}
  515. onFocus={this.handleFocus}
  516. placeholder={this.props.placeholder}
  517. plugins={this.plugins}
  518. spellCheck={false}
  519. value={this.state.value}
  520. />
  521. </div>
  522. );
  523. }
  524. }
  525. export default QueryField;