QueryField.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. // @ts-ignore
  2. import _ from 'lodash';
  3. import React, { Context } from 'react';
  4. import ReactDOM from 'react-dom';
  5. // @ts-ignore
  6. import { Change, Value } from 'slate';
  7. // @ts-ignore
  8. import { Editor } from 'slate-react';
  9. // @ts-ignore
  10. import Plain from 'slate-plain-serializer';
  11. import classnames from 'classnames';
  12. import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';
  13. import ClearPlugin from './slate-plugins/clear';
  14. import NewlinePlugin from './slate-plugins/newline';
  15. import { TypeaheadWithTheme } from './Typeahead';
  16. import { makeFragment, makeValue } from './Value';
  17. import PlaceholdersBuffer from './PlaceholdersBuffer';
  18. export const TYPEAHEAD_DEBOUNCE = 100;
  19. function getSuggestionByIndex(suggestions: CompletionItemGroup[], index: number): CompletionItem {
  20. // Flatten suggestion groups
  21. const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []);
  22. const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length;
  23. return flattenedSuggestions[correctedIndex];
  24. }
  25. function hasSuggestions(suggestions: CompletionItemGroup[]): boolean {
  26. return suggestions && suggestions.length > 0;
  27. }
  28. export interface QueryFieldProps {
  29. additionalPlugins?: any[];
  30. cleanText?: (text: string) => string;
  31. disabled?: boolean;
  32. initialQuery: string | null;
  33. onExecuteQuery?: () => void;
  34. onQueryChange?: (value: string) => void;
  35. onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput;
  36. onWillApplySuggestion?: (suggestion: string, state: QueryFieldState) => string;
  37. placeholder?: string;
  38. portalOrigin?: string;
  39. syntax?: string;
  40. syntaxLoaded?: boolean;
  41. }
  42. export interface QueryFieldState {
  43. suggestions: CompletionItemGroup[];
  44. typeaheadContext: string | null;
  45. typeaheadIndex: number;
  46. typeaheadPrefix: string;
  47. typeaheadText: string;
  48. value: Value;
  49. lastExecutedValue: Value;
  50. }
  51. export interface TypeaheadInput {
  52. editorNode: Element;
  53. prefix: string;
  54. selection?: Selection;
  55. text: string;
  56. value: Value;
  57. wrapperNode: Element;
  58. }
  59. /**
  60. * Renders an editor field.
  61. * Pass initial value as initialQuery and listen to changes in props.onValueChanged.
  62. * This component can only process strings. Internally it uses Slate Value.
  63. * Implement props.onTypeahead to use suggestions, see PromQueryField.tsx as an example.
  64. */
  65. export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldState> {
  66. menuEl: HTMLElement | null;
  67. placeholdersBuffer: PlaceholdersBuffer;
  68. plugins: any[];
  69. resetTimer: any;
  70. mounted: boolean;
  71. constructor(props: QueryFieldProps, context: Context<any>) {
  72. super(props, context);
  73. this.placeholdersBuffer = new PlaceholdersBuffer(props.initialQuery || '');
  74. // Base plugins
  75. this.plugins = [ClearPlugin(), NewlinePlugin(), ...(props.additionalPlugins || [])].filter(p => p);
  76. this.state = {
  77. suggestions: [],
  78. typeaheadContext: null,
  79. typeaheadIndex: 0,
  80. typeaheadPrefix: '',
  81. typeaheadText: '',
  82. value: makeValue(this.placeholdersBuffer.toString(), props.syntax),
  83. lastExecutedValue: null,
  84. };
  85. }
  86. componentDidMount() {
  87. this.mounted = true;
  88. this.updateMenu();
  89. }
  90. componentWillUnmount() {
  91. this.mounted = false;
  92. clearTimeout(this.resetTimer);
  93. }
  94. componentDidUpdate(prevProps: QueryFieldProps, prevState: QueryFieldState) {
  95. const { initialQuery, syntax } = this.props;
  96. const { value, suggestions } = this.state;
  97. // if query changed from the outside
  98. if (initialQuery !== prevProps.initialQuery) {
  99. // and we have a version that differs
  100. if (initialQuery !== Plain.serialize(value)) {
  101. this.placeholdersBuffer = new PlaceholdersBuffer(initialQuery || '');
  102. this.setState({ value: makeValue(this.placeholdersBuffer.toString(), syntax) });
  103. }
  104. }
  105. // Only update menu location when suggestion existence or text/selection changed
  106. if (value !== prevState.value || hasSuggestions(suggestions) !== hasSuggestions(prevState.suggestions)) {
  107. this.updateMenu();
  108. }
  109. }
  110. componentWillReceiveProps(nextProps: QueryFieldProps) {
  111. if (nextProps.syntaxLoaded && !this.props.syntaxLoaded) {
  112. // Need a bogus edit to re-render the editor after syntax has fully loaded
  113. const change = this.state.value
  114. .change()
  115. .insertText(' ')
  116. .deleteBackward();
  117. if (this.placeholdersBuffer.hasPlaceholders()) {
  118. change.move(this.placeholdersBuffer.getNextMoveOffset()).focus();
  119. }
  120. this.onChange(change, true);
  121. }
  122. }
  123. onChange = ({ value }: Change, invokeParentOnValueChanged?: boolean) => {
  124. const documentChanged = value.document !== this.state.value.document;
  125. const prevValue = this.state.value;
  126. // Control editor loop, then pass text change up to parent
  127. this.setState({ value }, () => {
  128. if (documentChanged) {
  129. const textChanged = Plain.serialize(prevValue) !== Plain.serialize(value);
  130. if (textChanged && invokeParentOnValueChanged) {
  131. this.executeOnQueryChangeAndExecuteQueries();
  132. }
  133. }
  134. });
  135. // Show suggest menu on text input
  136. if (documentChanged && value.selection.isCollapsed) {
  137. // Need one paint to allow DOM-based typeahead rules to work
  138. window.requestAnimationFrame(this.handleTypeahead);
  139. } else if (!this.resetTimer) {
  140. this.resetTypeahead();
  141. }
  142. };
  143. executeOnQueryChangeAndExecuteQueries = () => {
  144. // Send text change to parent
  145. const { onQueryChange, onExecuteQuery } = this.props;
  146. if (onQueryChange) {
  147. onQueryChange(Plain.serialize(this.state.value));
  148. }
  149. if (onExecuteQuery) {
  150. onExecuteQuery();
  151. this.setState({ lastExecutedValue: this.state.value });
  152. }
  153. };
  154. handleTypeahead = _.debounce(async () => {
  155. const selection = window.getSelection();
  156. const { cleanText, onTypeahead } = this.props;
  157. const { value } = this.state;
  158. if (onTypeahead && selection.anchorNode) {
  159. const wrapperNode = selection.anchorNode.parentElement;
  160. const editorNode = wrapperNode.closest('.slate-query-field');
  161. if (!editorNode || this.state.value.isBlurred) {
  162. // Not inside this editor
  163. return;
  164. }
  165. const range = selection.getRangeAt(0);
  166. const offset = range.startOffset;
  167. const text = selection.anchorNode.textContent;
  168. let prefix = text.substr(0, offset);
  169. // Label values could have valid characters erased if `cleanText()` is
  170. // blindly applied, which would undesirably interfere with suggestions
  171. const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/);
  172. if (labelValueMatch) {
  173. prefix = labelValueMatch[1];
  174. } else if (cleanText) {
  175. prefix = cleanText(prefix);
  176. }
  177. const { suggestions, context, refresher } = onTypeahead({
  178. editorNode,
  179. prefix,
  180. selection,
  181. text,
  182. value,
  183. wrapperNode,
  184. });
  185. let filteredSuggestions = suggestions
  186. .map(group => {
  187. if (group.items) {
  188. if (prefix) {
  189. // Filter groups based on prefix
  190. if (!group.skipFilter) {
  191. group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length);
  192. if (group.prefixMatch) {
  193. group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) === 0);
  194. } else {
  195. group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) > -1);
  196. }
  197. }
  198. // Filter out the already typed value (prefix) unless it inserts custom text
  199. group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix);
  200. }
  201. if (!group.skipSort) {
  202. group.items = _.sortBy(group.items, (item: CompletionItem) => item.sortText || item.label);
  203. }
  204. }
  205. return group;
  206. })
  207. .filter(group => group.items && group.items.length > 0); // Filter out empty groups
  208. // Keep same object for equality checking later
  209. if (_.isEqual(filteredSuggestions, this.state.suggestions)) {
  210. filteredSuggestions = this.state.suggestions;
  211. }
  212. this.setState(
  213. {
  214. suggestions: filteredSuggestions,
  215. typeaheadPrefix: prefix,
  216. typeaheadContext: context,
  217. typeaheadText: text,
  218. },
  219. () => {
  220. if (refresher) {
  221. refresher.then(this.handleTypeahead).catch(e => console.error(e));
  222. }
  223. }
  224. );
  225. }
  226. }, TYPEAHEAD_DEBOUNCE);
  227. applyTypeahead(change: Change, suggestion: CompletionItem): Change {
  228. const { cleanText, onWillApplySuggestion, syntax } = this.props;
  229. const { typeaheadPrefix, typeaheadText } = this.state;
  230. let suggestionText = suggestion.insertText || suggestion.label;
  231. const preserveSuffix = suggestion.kind === 'function';
  232. const move = suggestion.move || 0;
  233. if (onWillApplySuggestion) {
  234. suggestionText = onWillApplySuggestion(suggestionText, { ...this.state });
  235. }
  236. this.resetTypeahead();
  237. // Remove the current, incomplete text and replace it with the selected suggestion
  238. const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
  239. const text = cleanText ? cleanText(typeaheadText) : typeaheadText;
  240. const suffixLength = text.length - typeaheadPrefix.length;
  241. const offset = typeaheadText.indexOf(typeaheadPrefix);
  242. const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
  243. const forward = midWord && !preserveSuffix ? suffixLength + offset : 0;
  244. // If new-lines, apply suggestion as block
  245. if (suggestionText.match(/\n/)) {
  246. const fragment = makeFragment(suggestionText, syntax);
  247. return change
  248. .deleteBackward(backward)
  249. .deleteForward(forward)
  250. .insertFragment(fragment)
  251. .focus();
  252. }
  253. return change
  254. .deleteBackward(backward)
  255. .deleteForward(forward)
  256. .insertText(suggestionText)
  257. .move(move)
  258. .focus();
  259. }
  260. handleEnterAndTabKey = (event: KeyboardEvent, change: Change) => {
  261. const { typeaheadIndex, suggestions } = this.state;
  262. if (this.menuEl) {
  263. // Dont blur input
  264. event.preventDefault();
  265. if (!suggestions || suggestions.length === 0) {
  266. return undefined;
  267. }
  268. const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex);
  269. const nextChange = this.applyTypeahead(change, suggestion);
  270. const insertTextOperation = nextChange.operations.find((operation: any) => operation.type === 'insert_text');
  271. if (insertTextOperation) {
  272. const suggestionText = insertTextOperation.text;
  273. this.placeholdersBuffer.setNextPlaceholderValue(suggestionText);
  274. if (this.placeholdersBuffer.hasPlaceholders()) {
  275. nextChange.move(this.placeholdersBuffer.getNextMoveOffset()).focus();
  276. }
  277. }
  278. return true;
  279. } else {
  280. this.executeOnQueryChangeAndExecuteQueries();
  281. return undefined;
  282. }
  283. };
  284. onKeyDown = (event: KeyboardEvent, change: Change) => {
  285. const { typeaheadIndex } = this.state;
  286. switch (event.key) {
  287. case 'Escape': {
  288. if (this.menuEl) {
  289. event.preventDefault();
  290. event.stopPropagation();
  291. this.resetTypeahead();
  292. return true;
  293. }
  294. break;
  295. }
  296. case ' ': {
  297. if (event.ctrlKey) {
  298. event.preventDefault();
  299. this.handleTypeahead();
  300. return true;
  301. }
  302. break;
  303. }
  304. case 'Enter':
  305. case 'Tab': {
  306. return this.handleEnterAndTabKey(event, change);
  307. break;
  308. }
  309. case 'ArrowDown': {
  310. if (this.menuEl) {
  311. // Select next suggestion
  312. event.preventDefault();
  313. const itemsCount =
  314. this.state.suggestions.length > 0
  315. ? this.state.suggestions.reduce((totalCount, current) => totalCount + current.items.length, 0)
  316. : 0;
  317. this.setState({ typeaheadIndex: Math.min(itemsCount - 1, typeaheadIndex + 1) });
  318. }
  319. break;
  320. }
  321. case 'ArrowUp': {
  322. if (this.menuEl) {
  323. // Select previous suggestion
  324. event.preventDefault();
  325. this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) });
  326. }
  327. break;
  328. }
  329. default: {
  330. // console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
  331. break;
  332. }
  333. }
  334. return undefined;
  335. };
  336. resetTypeahead = () => {
  337. if (this.mounted) {
  338. this.setState({ suggestions: [], typeaheadIndex: 0, typeaheadPrefix: '', typeaheadContext: null });
  339. this.resetTimer = null;
  340. }
  341. };
  342. handleBlur = (event: FocusEvent, change: Change) => {
  343. const { lastExecutedValue } = this.state;
  344. const previousValue = lastExecutedValue ? Plain.serialize(this.state.lastExecutedValue) : null;
  345. const currentValue = Plain.serialize(change.value);
  346. // If we dont wait here, menu clicks wont work because the menu
  347. // will be gone.
  348. this.resetTimer = setTimeout(this.resetTypeahead, 100);
  349. // Disrupting placeholder entry wipes all remaining placeholders needing input
  350. this.placeholdersBuffer.clearPlaceholders();
  351. if (previousValue !== currentValue) {
  352. this.executeOnQueryChangeAndExecuteQueries();
  353. }
  354. };
  355. handleFocus = () => {};
  356. onClickMenu = (item: CompletionItem) => {
  357. // Manually triggering change
  358. const change = this.applyTypeahead(this.state.value.change(), item);
  359. this.onChange(change, true);
  360. };
  361. updateMenu = () => {
  362. const { suggestions } = this.state;
  363. const menu = this.menuEl;
  364. const selection = window.getSelection();
  365. const node = selection.anchorNode;
  366. // No menu, nothing to do
  367. if (!menu) {
  368. return;
  369. }
  370. // No suggestions or blur, remove menu
  371. if (!hasSuggestions(suggestions)) {
  372. menu.removeAttribute('style');
  373. return;
  374. }
  375. // Align menu overlay to editor node
  376. if (node) {
  377. // Read from DOM
  378. const rect = node.parentElement.getBoundingClientRect();
  379. const scrollX = window.scrollX;
  380. const scrollY = window.scrollY;
  381. // Write DOM
  382. requestAnimationFrame(() => {
  383. menu.style.opacity = '1';
  384. menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
  385. menu.style.left = `${rect.left + scrollX - 2}px`;
  386. });
  387. }
  388. };
  389. menuRef = (el: HTMLElement) => {
  390. this.menuEl = el;
  391. };
  392. renderMenu = () => {
  393. const { portalOrigin } = this.props;
  394. const { suggestions, typeaheadIndex, typeaheadPrefix } = this.state;
  395. if (!hasSuggestions(suggestions)) {
  396. return null;
  397. }
  398. const selectedItem = getSuggestionByIndex(suggestions, typeaheadIndex);
  399. // Create typeahead in DOM root so we can later position it absolutely
  400. return (
  401. <Portal origin={portalOrigin}>
  402. <TypeaheadWithTheme
  403. menuRef={this.menuRef}
  404. selectedItem={selectedItem}
  405. onClickItem={this.onClickMenu}
  406. prefix={typeaheadPrefix}
  407. groupedItems={suggestions}
  408. typeaheadIndex={typeaheadIndex}
  409. />
  410. </Portal>
  411. );
  412. };
  413. handlePaste = (event: ClipboardEvent, change: Editor) => {
  414. const pastedValue = event.clipboardData.getData('Text');
  415. const newValue = change.value.change().insertText(pastedValue);
  416. this.onChange(newValue);
  417. return true;
  418. };
  419. render() {
  420. const { disabled } = this.props;
  421. const wrapperClassName = classnames('slate-query-field__wrapper', {
  422. 'slate-query-field__wrapper--disabled': disabled,
  423. });
  424. return (
  425. <div className={wrapperClassName}>
  426. <div className="slate-query-field">
  427. {this.renderMenu()}
  428. <Editor
  429. autoCorrect={false}
  430. readOnly={this.props.disabled}
  431. onBlur={this.handleBlur}
  432. onKeyDown={this.onKeyDown}
  433. onChange={this.onChange}
  434. onFocus={this.handleFocus}
  435. onPaste={this.handlePaste}
  436. placeholder={this.props.placeholder}
  437. plugins={this.plugins}
  438. spellCheck={false}
  439. value={this.state.value}
  440. />
  441. </div>
  442. </div>
  443. );
  444. }
  445. }
  446. interface PortalProps {
  447. index?: number;
  448. origin: string;
  449. }
  450. class Portal extends React.PureComponent<PortalProps, {}> {
  451. node: HTMLElement;
  452. constructor(props: PortalProps) {
  453. super(props);
  454. const { index = 0, origin = 'query' } = props;
  455. this.node = document.createElement('div');
  456. this.node.classList.add(`slate-typeahead`, `slate-typeahead-${origin}-${index}`);
  457. document.body.appendChild(this.node);
  458. }
  459. componentWillUnmount() {
  460. document.body.removeChild(this.node);
  461. }
  462. render() {
  463. return ReactDOM.createPortal(this.props.children, this.node);
  464. }
  465. }
  466. export default QueryField;