suggestions.tsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  1. import React from 'react';
  2. import debounce from 'lodash/debounce';
  3. import sortBy from 'lodash/sortBy';
  4. import { Editor as CoreEditor } from 'slate';
  5. import { Plugin as SlatePlugin } from '@grafana/slate-react';
  6. import { TypeaheadOutput, CompletionItem, CompletionItemGroup } from 'app/types';
  7. import { QueryField, TypeaheadInput } from '../QueryField';
  8. import TOKEN_MARK from '@grafana/ui/src/slate-plugins/slate-prism/TOKEN_MARK';
  9. import { TypeaheadWithTheme, Typeahead } from '../Typeahead';
  10. import { makeFragment } from '@grafana/ui';
  11. export const TYPEAHEAD_DEBOUNCE = 100;
  12. export interface SuggestionsState {
  13. groupedItems: CompletionItemGroup[];
  14. typeaheadPrefix: string;
  15. typeaheadContext: string;
  16. typeaheadText: string;
  17. }
  18. let state: SuggestionsState = {
  19. groupedItems: [],
  20. typeaheadPrefix: '',
  21. typeaheadContext: '',
  22. typeaheadText: '',
  23. };
  24. export default function SuggestionsPlugin({
  25. onTypeahead,
  26. cleanText,
  27. onWillApplySuggestion,
  28. syntax,
  29. portalOrigin,
  30. component,
  31. }: {
  32. onTypeahead: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>;
  33. cleanText?: (text: string) => string;
  34. onWillApplySuggestion?: (suggestion: string, state: SuggestionsState) => string;
  35. syntax?: string;
  36. portalOrigin: string;
  37. component: QueryField; // Need to attach typeaheadRef here
  38. }): SlatePlugin {
  39. return {
  40. onBlur: (event, editor, next) => {
  41. state = {
  42. ...state,
  43. groupedItems: [],
  44. };
  45. return next();
  46. },
  47. onClick: (event, editor, next) => {
  48. state = {
  49. ...state,
  50. groupedItems: [],
  51. };
  52. return next();
  53. },
  54. onKeyDown: (event: KeyboardEvent, editor, next) => {
  55. const currentSuggestions = state.groupedItems;
  56. const hasSuggestions = currentSuggestions.length;
  57. switch (event.key) {
  58. case 'Escape': {
  59. if (hasSuggestions) {
  60. event.preventDefault();
  61. state = {
  62. ...state,
  63. groupedItems: [],
  64. };
  65. // Bogus edit to re-render editor
  66. return editor.insertText('');
  67. }
  68. break;
  69. }
  70. case 'ArrowDown':
  71. case 'ArrowUp':
  72. if (hasSuggestions) {
  73. event.preventDefault();
  74. component.typeaheadRef.moveMenuIndex(event.key === 'ArrowDown' ? 1 : -1);
  75. return;
  76. }
  77. break;
  78. case 'Enter':
  79. case 'Tab': {
  80. if (hasSuggestions) {
  81. event.preventDefault();
  82. component.typeaheadRef.insertSuggestion();
  83. return handleTypeahead(event, editor, next, onTypeahead, cleanText);
  84. }
  85. break;
  86. }
  87. default: {
  88. handleTypeahead(event, editor, next, onTypeahead, cleanText);
  89. break;
  90. }
  91. }
  92. return next();
  93. },
  94. commands: {
  95. selectSuggestion: (editor: CoreEditor, suggestion: CompletionItem): CoreEditor => {
  96. const suggestions = state.groupedItems;
  97. if (!suggestions || !suggestions.length) {
  98. return editor;
  99. }
  100. // @ts-ignore
  101. return editor.applyTypeahead(suggestion);
  102. },
  103. applyTypeahead: (editor: CoreEditor, suggestion: CompletionItem): CoreEditor => {
  104. let suggestionText = suggestion.insertText || suggestion.label;
  105. const preserveSuffix = suggestion.kind === 'function';
  106. const move = suggestion.move || 0;
  107. const { typeaheadPrefix, typeaheadText, typeaheadContext } = state;
  108. if (onWillApplySuggestion) {
  109. suggestionText = onWillApplySuggestion(suggestionText, {
  110. groupedItems: state.groupedItems,
  111. typeaheadContext,
  112. typeaheadPrefix,
  113. typeaheadText,
  114. });
  115. }
  116. // Remove the current, incomplete text and replace it with the selected suggestion
  117. const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
  118. const text = cleanText ? cleanText(typeaheadText) : typeaheadText;
  119. const suffixLength = text.length - typeaheadPrefix.length;
  120. const offset = typeaheadText.indexOf(typeaheadPrefix);
  121. const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
  122. const forward = midWord && !preserveSuffix ? suffixLength + offset : 0;
  123. // If new-lines, apply suggestion as block
  124. if (suggestionText.match(/\n/)) {
  125. const fragment = makeFragment(suggestionText);
  126. return editor
  127. .deleteBackward(backward)
  128. .deleteForward(forward)
  129. .insertFragment(fragment)
  130. .focus();
  131. }
  132. state = {
  133. ...state,
  134. groupedItems: [],
  135. };
  136. return editor
  137. .deleteBackward(backward)
  138. .deleteForward(forward)
  139. .insertText(suggestionText)
  140. .moveForward(move)
  141. .focus();
  142. },
  143. },
  144. renderEditor: (props, editor, next) => {
  145. if (editor.value.selection.isExpanded) {
  146. return next();
  147. }
  148. const children = next();
  149. return (
  150. <>
  151. {children}
  152. <TypeaheadWithTheme
  153. menuRef={(el: Typeahead) => (component.typeaheadRef = el)}
  154. origin={portalOrigin}
  155. prefix={state.typeaheadPrefix}
  156. isOpen={!!state.groupedItems.length}
  157. groupedItems={state.groupedItems}
  158. //@ts-ignore
  159. onSelectSuggestion={editor.selectSuggestion}
  160. />
  161. </>
  162. );
  163. },
  164. };
  165. }
  166. const handleTypeahead = debounce(
  167. async (
  168. event: Event,
  169. editor: CoreEditor,
  170. next: () => {},
  171. onTypeahead?: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>,
  172. cleanText?: (text: string) => string
  173. ) => {
  174. if (!onTypeahead) {
  175. return next();
  176. }
  177. const { value } = editor;
  178. const { selection } = value;
  179. // Get decorations associated with the current line
  180. const parentBlock = value.document.getClosestBlock(value.focusBlock.key);
  181. const myOffset = value.selection.start.offset - 1;
  182. const decorations = parentBlock.getDecorations(editor as any);
  183. const filteredDecorations = decorations
  184. .filter(
  185. decoration =>
  186. decoration.start.offset <= myOffset && decoration.end.offset > myOffset && decoration.type === TOKEN_MARK
  187. )
  188. .toArray();
  189. const labelKeyDec = decorations
  190. .filter(
  191. decoration =>
  192. decoration.end.offset === myOffset &&
  193. decoration.type === TOKEN_MARK &&
  194. decoration.data.get('className').includes('label-key')
  195. )
  196. .first();
  197. const labelKey = labelKeyDec && value.focusText.text.slice(labelKeyDec.start.offset, labelKeyDec.end.offset);
  198. const wrapperClasses = filteredDecorations
  199. .map(decoration => decoration.data.get('className'))
  200. .join(' ')
  201. .split(' ')
  202. .filter(className => className.length);
  203. let text = value.focusText.text;
  204. let prefix = text.slice(0, selection.focus.offset);
  205. if (filteredDecorations.length) {
  206. text = value.focusText.text.slice(filteredDecorations[0].start.offset, filteredDecorations[0].end.offset);
  207. prefix = value.focusText.text.slice(filteredDecorations[0].start.offset, selection.focus.offset);
  208. }
  209. // Label values could have valid characters erased if `cleanText()` is
  210. // blindly applied, which would undesirably interfere with suggestions
  211. const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/);
  212. if (labelValueMatch) {
  213. prefix = labelValueMatch[1];
  214. } else if (cleanText) {
  215. prefix = cleanText(prefix);
  216. }
  217. const { suggestions, context } = await onTypeahead({
  218. prefix,
  219. text,
  220. value,
  221. wrapperClasses,
  222. labelKey,
  223. });
  224. const filteredSuggestions = suggestions
  225. .map(group => {
  226. if (!group.items) {
  227. return group;
  228. }
  229. if (prefix) {
  230. // Filter groups based on prefix
  231. if (!group.skipFilter) {
  232. group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length);
  233. if (group.prefixMatch) {
  234. group.items = group.items.filter(c => (c.filterText || c.label).startsWith(prefix));
  235. } else {
  236. group.items = group.items.filter(c => (c.filterText || c.label).includes(prefix));
  237. }
  238. }
  239. // Filter out the already typed value (prefix) unless it inserts custom text
  240. group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix);
  241. }
  242. if (!group.skipSort) {
  243. group.items = sortBy(group.items, (item: CompletionItem) => item.sortText || item.label);
  244. }
  245. return group;
  246. })
  247. .filter(group => group.items && group.items.length); // Filter out empty groups
  248. state = {
  249. ...state,
  250. groupedItems: filteredSuggestions,
  251. typeaheadPrefix: prefix,
  252. typeaheadContext: context,
  253. typeaheadText: text,
  254. };
  255. // Bogus edit to force re-render
  256. return editor.insertText('');
  257. },
  258. TYPEAHEAD_DEBOUNCE
  259. );