QueryField.tsx 16 KB

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