MonacoQueryField.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. import { css } from '@emotion/css';
  2. import { promLanguageDefinition } from 'monaco-promql';
  3. import React, { useRef, useEffect } from 'react';
  4. import { useLatest } from 'react-use';
  5. import { GrafanaTheme2 } from '@grafana/data';
  6. import { selectors } from '@grafana/e2e-selectors';
  7. import { useTheme2, ReactMonacoEditor, Monaco, monacoTypes } from '@grafana/ui';
  8. import { Props } from './MonacoQueryFieldProps';
  9. import { getOverrideServices } from './getOverrideServices';
  10. import { getCompletionProvider, getSuggestOptions } from './monaco-completion-provider';
  11. const options: monacoTypes.editor.IStandaloneEditorConstructionOptions = {
  12. codeLens: false,
  13. contextmenu: false,
  14. // we need `fixedOverflowWidgets` because otherwise in grafana-dashboards
  15. // the popup is clipped by the panel-visualizations.
  16. fixedOverflowWidgets: true,
  17. folding: false,
  18. fontSize: 14,
  19. lineDecorationsWidth: 8, // used as "padding-left"
  20. lineNumbers: 'off',
  21. minimap: { enabled: false },
  22. overviewRulerBorder: false,
  23. overviewRulerLanes: 0,
  24. padding: {
  25. // these numbers were picked so that visually this matches the previous version
  26. // of the query-editor the best
  27. top: 4,
  28. bottom: 5,
  29. },
  30. renderLineHighlight: 'none',
  31. scrollbar: {
  32. vertical: 'hidden',
  33. verticalScrollbarSize: 8, // used as "padding-right"
  34. horizontal: 'hidden',
  35. horizontalScrollbarSize: 0,
  36. },
  37. scrollBeyondLastLine: false,
  38. suggest: getSuggestOptions(),
  39. suggestFontSize: 12,
  40. wordWrap: 'on',
  41. };
  42. // this number was chosen by testing various values. it might be necessary
  43. // because of the width of the border, not sure.
  44. //it needs to do 2 things:
  45. // 1. when the editor is single-line, it should make the editor height be visually correct
  46. // 2. when the editor is multi-line, the editor should not be "scrollable" (meaning,
  47. // you do a scroll-movement in the editor, and it will scroll the content by a couple pixels
  48. // up & down. this we want to avoid)
  49. const EDITOR_HEIGHT_OFFSET = 2;
  50. const PROMQL_LANG_ID = promLanguageDefinition.id;
  51. // we must only run the promql-setup code once
  52. let PROMQL_SETUP_STARTED = false;
  53. function ensurePromQL(monaco: Monaco) {
  54. if (PROMQL_SETUP_STARTED === false) {
  55. PROMQL_SETUP_STARTED = true;
  56. const { aliases, extensions, mimetypes, loader } = promLanguageDefinition;
  57. monaco.languages.register({ id: PROMQL_LANG_ID, aliases, extensions, mimetypes });
  58. loader().then((mod) => {
  59. monaco.languages.setMonarchTokensProvider(PROMQL_LANG_ID, mod.language);
  60. monaco.languages.setLanguageConfiguration(PROMQL_LANG_ID, mod.languageConfiguration);
  61. });
  62. }
  63. }
  64. const getStyles = (theme: GrafanaTheme2) => {
  65. return {
  66. container: css`
  67. border-radius: ${theme.shape.borderRadius()};
  68. border: 1px solid ${theme.components.input.borderColor};
  69. `,
  70. };
  71. };
  72. const MonacoQueryField = (props: Props) => {
  73. // we need only one instance of `overrideServices` during the lifetime of the react component
  74. const overrideServicesRef = useRef(getOverrideServices());
  75. const containerRef = useRef<HTMLDivElement>(null);
  76. const { languageProvider, history, onBlur, onRunQuery, initialValue } = props;
  77. const lpRef = useLatest(languageProvider);
  78. const historyRef = useLatest(history);
  79. const onRunQueryRef = useLatest(onRunQuery);
  80. const onBlurRef = useLatest(onBlur);
  81. const autocompleteDisposeFun = useRef<(() => void) | null>(null);
  82. const theme = useTheme2();
  83. const styles = getStyles(theme);
  84. useEffect(() => {
  85. // when we unmount, we unregister the autocomplete-function, if it was registered
  86. return () => {
  87. autocompleteDisposeFun.current?.();
  88. };
  89. }, []);
  90. return (
  91. <div
  92. aria-label={selectors.components.QueryField.container}
  93. className={styles.container}
  94. // NOTE: we will be setting inline-style-width/height on this element
  95. ref={containerRef}
  96. >
  97. <ReactMonacoEditor
  98. overrideServices={overrideServicesRef.current}
  99. options={options}
  100. language="promql"
  101. value={initialValue}
  102. beforeMount={(monaco) => {
  103. ensurePromQL(monaco);
  104. }}
  105. onMount={(editor, monaco) => {
  106. // we setup on-blur
  107. editor.onDidBlurEditorWidget(() => {
  108. onBlurRef.current(editor.getValue());
  109. });
  110. // we construct a DataProvider object
  111. const getSeries = (selector: string) => lpRef.current.getSeries(selector);
  112. const getHistory = () =>
  113. Promise.resolve(historyRef.current.map((h) => h.query.expr).filter((expr) => expr !== undefined));
  114. const getAllMetricNames = () => {
  115. const { metrics, metricsMetadata } = lpRef.current;
  116. const result = metrics.map((m) => {
  117. const metaItem = metricsMetadata?.[m];
  118. return {
  119. name: m,
  120. help: metaItem?.help ?? '',
  121. type: metaItem?.type ?? '',
  122. };
  123. });
  124. return Promise.resolve(result);
  125. };
  126. const getAllLabelNames = () => Promise.resolve(lpRef.current.getLabelKeys());
  127. const getLabelValues = (labelName: string) => lpRef.current.getLabelValues(labelName);
  128. const dataProvider = { getSeries, getHistory, getAllMetricNames, getAllLabelNames, getLabelValues };
  129. const completionProvider = getCompletionProvider(monaco, dataProvider);
  130. // completion-providers in monaco are not registered directly to editor-instances,
  131. // they are registered to languages. this makes it hard for us to have
  132. // separate completion-providers for every query-field-instance
  133. // (but we need that, because they might connect to different datasources).
  134. // the trick we do is, we wrap the callback in a "proxy",
  135. // and in the proxy, the first thing is, we check if we are called from
  136. // "our editor instance", and if not, we just return nothing. if yes,
  137. // we call the completion-provider.
  138. const filteringCompletionProvider: monacoTypes.languages.CompletionItemProvider = {
  139. ...completionProvider,
  140. provideCompletionItems: (model, position, context, token) => {
  141. // if the model-id does not match, then this call is from a different editor-instance,
  142. // not "our instance", so return nothing
  143. if (editor.getModel()?.id !== model.id) {
  144. return { suggestions: [] };
  145. }
  146. return completionProvider.provideCompletionItems(model, position, context, token);
  147. },
  148. };
  149. const { dispose } = monaco.languages.registerCompletionItemProvider(
  150. PROMQL_LANG_ID,
  151. filteringCompletionProvider
  152. );
  153. autocompleteDisposeFun.current = dispose;
  154. // this code makes the editor resize itself so that the content fits
  155. // (it will grow taller when necessary)
  156. // FIXME: maybe move this functionality into CodeEditor, like:
  157. // <CodeEditor resizingMode="single-line"/>
  158. const updateElementHeight = () => {
  159. const containerDiv = containerRef.current;
  160. if (containerDiv !== null) {
  161. const pixelHeight = editor.getContentHeight();
  162. containerDiv.style.height = `${pixelHeight + EDITOR_HEIGHT_OFFSET}px`;
  163. containerDiv.style.width = '100%';
  164. const pixelWidth = containerDiv.clientWidth;
  165. editor.layout({ width: pixelWidth, height: pixelHeight });
  166. }
  167. };
  168. editor.onDidContentSizeChange(updateElementHeight);
  169. updateElementHeight();
  170. // handle: shift + enter
  171. // FIXME: maybe move this functionality into CodeEditor?
  172. editor.addCommand(monaco.KeyMod.Shift | monaco.KeyCode.Enter, () => {
  173. onRunQueryRef.current(editor.getValue());
  174. });
  175. /* Something in this configuration of monaco doesn't bubble up [mod]+K, which the
  176. command palette uses. Pass the event out of monaco manually
  177. */
  178. editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK, function () {
  179. global.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }));
  180. });
  181. }}
  182. />
  183. </div>
  184. );
  185. };
  186. // we will lazy-load this module using React.lazy,
  187. // and that only supports default-exports,
  188. // so we have to default-export this, even if
  189. // it is against the style-guidelines.
  190. export default MonacoQueryField;