QueryField.tsx 17 KB

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