Selaa lähdekoodia

Chore: Update Slate to 0.47.8 (#18412)

* Chore: Update Slate to 0.47.8
Closes #17430

* Add slate and immutable to grafana-ui deps

* Fixes some small regressions introduced

* Fix suggestions for multiple query fields

* Pin upgraded dependencies

* Prettier fix

* Remove original slate-react dependency

* Fix tiny-invariant dep

* (Temporarily) comments out source maps for grafana-ui
kay delaney 6 vuotta sitten
vanhempi
commit
601853fc84
51 muutettua tiedostoa jossa 1705 lisäystä ja 1318 poistoa
  1. 6 5
      package.json
  2. 1 1
      packages/grafana-toolkit/src/config/webpack.plugin.config.ts
  3. 5 0
      packages/grafana-ui/package.json
  4. 4 3
      packages/grafana-ui/rollup.config.ts
  5. 24 22
      packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx
  6. 1 0
      packages/grafana-ui/src/index.ts
  7. 1 0
      packages/grafana-ui/src/slate-plugins/index.ts
  8. 3 0
      packages/grafana-ui/src/slate-plugins/slate-prism/TOKEN_MARK.ts
  9. 160 0
      packages/grafana-ui/src/slate-plugins/slate-prism/index.ts
  10. 77 0
      packages/grafana-ui/src/slate-plugins/slate-prism/options.tsx
  11. 13 14
      packages/grafana-ui/src/utils/slate.ts
  12. 4 0
      packages/grafana-ui/tsconfig.json
  13. 0 41
      public/app/features/explore/QueryField.test.tsx
  14. 50 557
      public/app/features/explore/QueryField.tsx
  15. 152 72
      public/app/features/explore/Typeahead.tsx
  16. 7 18
      public/app/features/explore/TypeaheadInfo.tsx
  17. 19 17
      public/app/features/explore/TypeaheadItem.tsx
  18. 0 39
      public/app/features/explore/slate-plugins/braces.test.ts
  19. 40 0
      public/app/features/explore/slate-plugins/braces.test.tsx
  20. 22 18
      public/app/features/explore/slate-plugins/braces.ts
  21. 0 39
      public/app/features/explore/slate-plugins/clear.test.ts
  22. 42 0
      public/app/features/explore/slate-plugins/clear.test.tsx
  23. 13 8
      public/app/features/explore/slate-plugins/clear.ts
  24. 61 0
      public/app/features/explore/slate-plugins/clipboard.ts
  25. 93 0
      public/app/features/explore/slate-plugins/indentation.ts
  26. 12 9
      public/app/features/explore/slate-plugins/newline.ts
  27. 17 0
      public/app/features/explore/slate-plugins/runner.test.tsx
  28. 5 2
      public/app/features/explore/slate-plugins/runner.ts
  29. 72 0
      public/app/features/explore/slate-plugins/selection_shortcuts.ts
  30. 313 0
      public/app/features/explore/slate-plugins/suggestions.tsx
  31. 3 5
      public/app/features/explore/utils/typeahead.ts
  32. 2 2
      public/app/features/plugins/plugin_loader.ts
  33. 3 5
      public/app/plugins/datasource/elasticsearch/components/ElasticsearchQueryField.tsx
  34. 9 8
      public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx
  35. 23 36
      public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/query_field.tsx
  36. 2 1
      public/app/plugins/datasource/loki/components/LokiQueryField.tsx
  37. 26 23
      public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx
  38. 1 0
      public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts
  39. 0 1
      public/app/plugins/datasource/loki/components/useLokiSyntax.ts
  40. 32 25
      public/app/plugins/datasource/loki/language_provider.test.ts
  41. 25 29
      public/app/plugins/datasource/loki/language_provider.ts
  42. 2 2
      public/app/plugins/datasource/loki/mocks.ts
  43. 3 1
      public/app/plugins/datasource/loki/syntax.ts
  44. 15 19
      public/app/plugins/datasource/prometheus/components/PromQueryField.tsx
  45. 85 60
      public/app/plugins/datasource/prometheus/language_provider.ts
  46. 4 4
      public/app/plugins/datasource/prometheus/language_utils.ts
  47. 111 111
      public/app/plugins/datasource/prometheus/specs/language_provider.test.ts
  48. 25 7
      public/app/types/explore.ts
  49. 4 4
      public/sass/components/_slate_editor.scss
  50. 2 1
      tsconfig.json
  51. 111 109
      yarn.lock

+ 6 - 5
package.json

@@ -52,7 +52,9 @@
     "@types/redux-logger": "3.0.7",
     "@types/redux-mock-store": "1.0.1",
     "@types/reselect": "2.2.0",
-    "@types/slate": "0.44.11",
+    "@types/slate": "0.47.1",
+    "@types/slate-plain-serializer": "0.6.1",
+    "@types/slate-react": "0.22.5",
     "@types/tinycolor2": "1.4.2",
     "angular-mocks": "1.6.6",
     "autoprefixer": "9.5.0",
@@ -193,6 +195,7 @@
   },
   "dependencies": {
     "@babel/polyfill": "7.2.5",
+    "@grafana/slate-react": "0.22.9-grafana",
     "@torkelo/react-select": "2.4.1",
     "angular": "1.6.6",
     "angular-bindonce": "0.3.1",
@@ -243,10 +246,8 @@
     "rst2html": "github:thoward/rst2html#990cb89",
     "rxjs": "6.4.0",
     "search-query-parser": "1.5.2",
-    "slate": "0.33.8",
-    "slate-plain-serializer": "0.5.41",
-    "slate-prism": "0.5.0",
-    "slate-react": "0.12.11",
+    "slate": "0.47.8",
+    "slate-plain-serializer": "0.7.10",
     "tether": "1.4.5",
     "tether-drop": "https://github.com/torkelo/drop/tarball/master",
     "tinycolor2": "1.4.1",

+ 1 - 1
packages/grafana-toolkit/src/config/webpack.plugin.config.ts

@@ -149,7 +149,7 @@ export const getWebpackConfig: WebpackConfigurationGetter = options => {
       'emotion',
       'prismjs',
       'slate-plain-serializer',
-      'slate-react',
+      '@grafana/slate-react',
       'react',
       'react-dom',
       'react-redux',

+ 5 - 0
packages/grafana-ui/package.json

@@ -26,10 +26,12 @@
   },
   "dependencies": {
     "@grafana/data": "^6.4.0-alpha",
+    "@grafana/slate-react": "0.22.9-grafana",
     "@torkelo/react-select": "2.1.1",
     "@types/react-color": "2.17.0",
     "classnames": "2.2.6",
     "d3": "5.9.1",
+    "immutable": "3.8.2",
     "jquery": "3.4.1",
     "lodash": "4.17.15",
     "moment": "2.24.0",
@@ -45,6 +47,7 @@
     "react-storybook-addon-props-combinations": "1.1.0",
     "react-transition-group": "2.6.1",
     "react-virtualized": "9.21.0",
+    "slate": "0.47.8",
     "tinycolor2": "1.4.1"
   },
   "devDependencies": {
@@ -65,6 +68,8 @@
     "@types/react-custom-scrollbars": "4.0.5",
     "@types/react-test-renderer": "16.8.1",
     "@types/react-transition-group": "2.0.16",
+    "@types/slate": "0.47.1",
+    "@types/slate-react": "0.22.5",
     "@types/storybook__addon-actions": "3.4.2",
     "@types/storybook__addon-info": "4.1.1",
     "@types/storybook__addon-knobs": "4.0.4",

+ 4 - 3
packages/grafana-ui/rollup.config.ts

@@ -1,6 +1,6 @@
 import resolve from 'rollup-plugin-node-resolve';
 import commonjs from 'rollup-plugin-commonjs';
-import sourceMaps from 'rollup-plugin-sourcemaps';
+// import sourceMaps from 'rollup-plugin-sourcemaps';
 import { terser } from 'rollup-plugin-terser';
 
 const pkg = require('./package.json');
@@ -47,19 +47,20 @@ const buildCjsPackage = ({ env }) => {
           ],
           '../../node_modules/react-color/lib/components/common': ['Saturation', 'Hue', 'Alpha'],
           '../../node_modules/immutable/dist/immutable.js': [
+            'Record',
             'Set',
             'Map',
             'List',
             'OrderedSet',
             'is',
             'Stack',
-            'Record',
           ],
+          'node_modules/immutable/dist/immutable.js': ['Record', 'Set', 'Map', 'List', 'OrderedSet', 'is', 'Stack'],
           '../../node_modules/esrever/esrever.js': ['reverse'],
         },
       }),
       resolve(),
-      sourceMaps(),
+      // sourceMaps(),
       env === 'production' && terser(),
     ],
   };

+ 24 - 22
packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx

@@ -1,19 +1,16 @@
 import React, { useState, useMemo, useCallback, useContext } from 'react';
 import { VariableSuggestion, VariableOrigin, DataLinkSuggestions } from './DataLinkSuggestions';
-import { makeValue, ThemeContext, DataLinkBuiltInVars } from '../../index';
+import { makeValue, ThemeContext, DataLinkBuiltInVars, SCHEMA } from '../../index';
 import { SelectionReference } from './SelectionReference';
 import { Portal } from '../index';
-// @ts-ignore
-import { Editor } from 'slate-react';
-// @ts-ignore
-import { Value, Change, Document } from 'slate';
-// @ts-ignore
+import { Editor } from '@grafana/slate-react';
+import { Value, Editor as CoreEditor } from 'slate';
 import Plain from 'slate-plain-serializer';
 import { Popper as ReactPopper } from 'react-popper';
 import useDebounce from 'react-use/lib/useDebounce';
 import { css, cx } from 'emotion';
-// @ts-ignore
-import PluginPrism from 'slate-prism';
+
+import { SlatePrism } from '../../slate-plugins';
 
 interface DataLinkInputProps {
   value: string;
@@ -22,7 +19,7 @@ interface DataLinkInputProps {
 }
 
 const plugins = [
-  PluginPrism({
+  SlatePrism({
     onlyIn: (node: any) => node.type === 'code_block',
     getSyntax: () => 'links',
   }),
@@ -79,27 +76,28 @@ export const DataLinkInput: React.FC<DataLinkInputProps> = ({ value, onChange, s
 
   useDebounce(updateUsedSuggestions, 250, [linkUrl]);
 
-  const onKeyDown = (event: KeyboardEvent) => {
-    if (event.key === 'Backspace' || event.key === 'Escape') {
+  const onKeyDown = (event: Event, editor: CoreEditor, next: Function) => {
+    const keyboardEvent = event as KeyboardEvent;
+    if (keyboardEvent.key === 'Backspace') {
       setShowingSuggestions(false);
       setSuggestionsIndex(0);
     }
 
-    if (event.key === 'Enter') {
+    if (keyboardEvent.key === 'Enter') {
       if (showingSuggestions) {
         onVariableSelect(currentSuggestions[suggestionsIndex]);
       }
     }
 
     if (showingSuggestions) {
-      if (event.key === 'ArrowDown') {
-        event.preventDefault();
+      if (keyboardEvent.key === 'ArrowDown') {
+        keyboardEvent.preventDefault();
         setSuggestionsIndex(index => {
           return (index + 1) % currentSuggestions.length;
         });
       }
-      if (event.key === 'ArrowUp') {
-        event.preventDefault();
+      if (keyboardEvent.key === 'ArrowUp') {
+        keyboardEvent.preventDefault();
         setSuggestionsIndex(index => {
           const nextIndex = index - 1 < 0 ? currentSuggestions.length - 1 : (index - 1) % currentSuggestions.length;
           return nextIndex;
@@ -107,21 +105,24 @@ export const DataLinkInput: React.FC<DataLinkInputProps> = ({ value, onChange, s
       }
     }
 
-    if (event.key === '?' || event.key === '&' || event.key === '$' || (event.keyCode === 32 && event.ctrlKey)) {
+    if (
+      keyboardEvent.key === '?' ||
+      keyboardEvent.key === '&' ||
+      keyboardEvent.key === '$' ||
+      (keyboardEvent.keyCode === 32 && keyboardEvent.ctrlKey)
+    ) {
       setShowingSuggestions(true);
     }
 
-    if (event.key === 'Enter' && showingSuggestions) {
-      // Preventing entering a new line
-      // As of https://github.com/ianstormtaylor/slate/issues/1345#issuecomment-340508289
-      return false;
+    if (keyboardEvent.key === 'Backspace') {
+      return next();
     } else {
       // @ts-ignore
       return;
     }
   };
 
-  const onUrlChange = ({ value }: Change) => {
+  const onUrlChange = ({ value }: { value: Value }) => {
     setLinkUrl(value);
   };
 
@@ -186,6 +187,7 @@ export const DataLinkInput: React.FC<DataLinkInputProps> = ({ value, onChange, s
           </Portal>
         )}
         <Editor
+          schema={SCHEMA}
           placeholder="http://your-grafana.com/d/000000010/annotations"
           value={linkUrl}
           onChange={onUrlChange}

+ 1 - 0
packages/grafana-ui/src/index.ts

@@ -2,3 +2,4 @@ export * from './components';
 export * from './types';
 export * from './utils';
 export * from './themes';
+export * from './slate-plugins';

+ 1 - 0
packages/grafana-ui/src/slate-plugins/index.ts

@@ -0,0 +1 @@
+export { SlatePrism } from './slate-prism';

+ 3 - 0
packages/grafana-ui/src/slate-plugins/slate-prism/TOKEN_MARK.ts

@@ -0,0 +1,3 @@
+const TOKEN_MARK = 'prism-token';
+
+export default TOKEN_MARK;

+ 160 - 0
packages/grafana-ui/src/slate-plugins/slate-prism/index.ts

@@ -0,0 +1,160 @@
+import Prism from 'prismjs';
+import { Block, Text, Decoration } from 'slate';
+import { Plugin } from '@grafana/slate-react';
+import Options, { OptionsFormat } from './options';
+import TOKEN_MARK from './TOKEN_MARK';
+
+/**
+ * A Slate plugin to highlight code syntax.
+ */
+export function SlatePrism(optsParam: OptionsFormat = {}): Plugin {
+  const opts: Options = new Options(optsParam);
+
+  return {
+    decorateNode: (node, editor, next) => {
+      if (!opts.onlyIn(node)) {
+        return next();
+      }
+      return decorateNode(opts, Block.create(node as Block));
+    },
+
+    renderDecoration: (props, editor, next) =>
+      opts.renderDecoration(
+        {
+          children: props.children,
+          decoration: props.decoration,
+        },
+        editor as any,
+        next
+      ),
+  };
+}
+
+/**
+ * Returns the decoration for a node
+ */
+function decorateNode(opts: Options, block: Block) {
+  const grammarName = opts.getSyntax(block);
+  const grammar = Prism.languages[grammarName];
+  if (!grammar) {
+    // Grammar not loaded
+    return [];
+  }
+
+  // Tokenize the whole block text
+  const texts = block.getTexts();
+  const blockText = texts.map(text => text && text.getText()).join('\n');
+  const tokens = Prism.tokenize(blockText, grammar);
+
+  // The list of decorations to return
+  const decorations: Decoration[] = [];
+  let textStart = 0;
+  let textEnd = 0;
+
+  texts.forEach(text => {
+    textEnd = textStart + text!.getText().length;
+
+    let offset = 0;
+    function processToken(token: string | Prism.Token, accu?: string | number) {
+      if (typeof token === 'string') {
+        if (accu) {
+          const decoration = createDecoration({
+            text: text!,
+            textStart,
+            textEnd,
+            start: offset,
+            end: offset + token.length,
+            className: `prism-token token ${accu}`,
+            block,
+          });
+          if (decoration) {
+            decorations.push(decoration);
+          }
+        }
+        offset += token.length;
+      } else {
+        accu = `${accu} ${token.type} ${token.alias || ''}`;
+
+        if (typeof token.content === 'string') {
+          const decoration = createDecoration({
+            text: text!,
+            textStart,
+            textEnd,
+            start: offset,
+            end: offset + token.content.length,
+            className: `prism-token token ${accu}`,
+            block,
+          });
+          if (decoration) {
+            decorations.push(decoration);
+          }
+
+          offset += token.content.length;
+        } else {
+          // When using token.content instead of token.matchedStr, token can be deep
+          for (let i = 0; i < token.content.length; i += 1) {
+            // @ts-ignore
+            processToken(token.content[i], accu);
+          }
+        }
+      }
+    }
+
+    tokens.forEach(processToken);
+    textStart = textEnd + 1; // account for added `\n`
+  });
+
+  return decorations;
+}
+
+/**
+ * Return a decoration range for the given text.
+ */
+function createDecoration({
+  text,
+  textStart,
+  textEnd,
+  start,
+  end,
+  className,
+  block,
+}: {
+  text: Text; // The text being decorated
+  textStart: number; // Its start position in the whole text
+  textEnd: number; // Its end position in the whole text
+  start: number; // The position in the whole text where the token starts
+  end: number; // The position in the whole text where the token ends
+  className: string; // The prism token classname
+  block: Block;
+}): Decoration | null {
+  if (start >= textEnd || end <= textStart) {
+    // Ignore, the token is not in the text
+    return null;
+  }
+
+  // Shrink to this text boundaries
+  start = Math.max(start, textStart);
+  end = Math.min(end, textEnd);
+
+  // Now shift offsets to be relative to this text
+  start -= textStart;
+  end -= textStart;
+
+  const myDec = block.createDecoration({
+    object: 'decoration',
+    anchor: {
+      key: text.key,
+      offset: start,
+      object: 'point',
+    },
+    focus: {
+      key: text.key,
+      offset: end,
+      object: 'point',
+    },
+    type: TOKEN_MARK,
+    data: { className },
+  });
+
+  return myDec;
+}

+ 77 - 0
packages/grafana-ui/src/slate-plugins/slate-prism/options.tsx

@@ -0,0 +1,77 @@
+import React from 'react';
+import { Mark, Node, Decoration } from 'slate';
+import { Editor } from '@grafana/slate-react';
+import { Record } from 'immutable';
+
+import TOKEN_MARK from './TOKEN_MARK';
+
+export interface OptionsFormat {
+  // Determine which node should be highlighted
+  onlyIn?: (node: Node) => boolean;
+  // Returns the syntax for a node that should be highlighted
+  getSyntax?: (node: Node) => string;
+  // Render a highlighting mark in a highlighted node
+  renderMark?: ({ mark, children }: { mark: Mark; children: React.ReactNode }) => void | React.ReactNode;
+}
+
+/**
+ * Default filter for code blocks
+ */
+function defaultOnlyIn(node: Node): boolean {
+  return node.object === 'block' && node.type === 'code_block';
+}
+
+/**
+ * Default getter for syntax
+ */
+function defaultGetSyntax(node: Node): string {
+  return 'javascript';
+}
+
+/**
+ * Default rendering for decorations
+ */
+function defaultRenderDecoration(
+  props: { children: React.ReactNode; decoration: Decoration },
+  editor: Editor,
+  next: () => any
+): void | React.ReactNode {
+  const { decoration } = props;
+  if (decoration.type !== TOKEN_MARK) {
+    return next();
+  }
+
+  const className = decoration.data.get('className');
+  return <span className={className}>{props.children}</span>;
+}
+
+/**
+ * The plugin options
+ */
+class Options
+  extends Record({
+    onlyIn: defaultOnlyIn,
+    getSyntax: defaultGetSyntax,
+    renderDecoration: defaultRenderDecoration,
+  })
+  implements OptionsFormat {
+  readonly onlyIn!: (node: Node) => boolean;
+  readonly getSyntax!: (node: Node) => string;
+  readonly renderDecoration!: (
+    {
+      decoration,
+      children,
+    }: {
+      decoration: Decoration;
+      children: React.ReactNode;
+    },
+    editor: Editor,
+    next: () => any
+  ) => void | React.ReactNode;
+
+  constructor(props: OptionsFormat) {
+    super(props);
+  }
+}
+
+export default Options;

+ 13 - 14
packages/grafana-ui/src/utils/slate.ts

@@ -1,22 +1,22 @@
-// @ts-ignore
-import { Block, Document, Text, Value } from 'slate';
+import { Block, Document, Text, Value, SchemaProperties } from 'slate';
 
-const SCHEMA = {
-  blocks: {
-    paragraph: 'paragraph',
-    codeblock: 'code_block',
-    codeline: 'code_line',
+export const SCHEMA: SchemaProperties = {
+  document: {
+    nodes: [
+      {
+        match: [{ type: 'paragraph' }, { type: 'code_block' }, { type: 'code_line' }],
+      },
+    ],
   },
   inlines: {},
-  marks: {},
 };
 
-export const makeFragment = (text: string, syntax?: string) => {
+export const makeFragment = (text: string, syntax?: string): Document => {
   const lines = text.split('\n').map(line =>
     Block.create({
       type: 'code_line',
       nodes: [Text.create(line)],
-    } as any)
+    })
   );
 
   const block = Block.create({
@@ -25,18 +25,17 @@ export const makeFragment = (text: string, syntax?: string) => {
     },
     type: 'code_block',
     nodes: lines,
-  } as any);
+  });
 
   return Document.create({
     nodes: [block],
   });
 };
 
-export const makeValue = (text: string, syntax?: string) => {
+export const makeValue = (text: string, syntax?: string): Value => {
   const fragment = makeFragment(text, syntax);
 
   return Value.create({
     document: fragment,
-    SCHEMA,
-  } as any);
+  });
 };

+ 4 - 0
packages/grafana-ui/tsconfig.json

@@ -5,6 +5,10 @@
   "compilerOptions": {
     "rootDirs": [".", "stories"],
     "typeRoots": ["./node_modules/@types", "types"],
+    "baseUrl": "./node_modules/@types",
+    "paths": {
+      "@grafana/slate-react": ["slate-react"]
+    },
     "declarationDir": "dist",
     "outDir": "compiled"
   }

+ 0 - 41
public/app/features/explore/QueryField.test.tsx

@@ -17,45 +17,4 @@ describe('<QueryField />', () => {
     const wrapper = shallow(<QueryField initialQuery="my query" />);
     expect(wrapper.find('div').exists()).toBeTruthy();
   });
-
-  it('should execute query when enter is pressed and there are no suggestions visible', () => {
-    const wrapper = shallow(<QueryField initialQuery="my query" />);
-    const instance = wrapper.instance() as QueryField;
-    instance.executeOnChangeAndRunQueries = jest.fn();
-    const handleEnterAndTabKeySpy = jest.spyOn(instance, 'handleEnterKey');
-    instance.onKeyDown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent, {});
-    expect(handleEnterAndTabKeySpy).toBeCalled();
-    expect(instance.executeOnChangeAndRunQueries).toBeCalled();
-  });
-
-  it('should copy selected text', () => {
-    const wrapper = shallow(<QueryField initialQuery="" />);
-    const instance = wrapper.instance() as QueryField;
-    const textBlocks = ['ignore this text. copy this text'];
-    const copiedText = instance.getCopiedText(textBlocks, 18, 32);
-
-    expect(copiedText).toBe('copy this text');
-  });
-
-  it('should copy selected text across 2 lines', () => {
-    const wrapper = shallow(<QueryField initialQuery="" />);
-    const instance = wrapper.instance() as QueryField;
-    const textBlocks = ['ignore this text. start copying here', 'lorem ipsum. stop copying here. lorem ipsum'];
-    const copiedText = instance.getCopiedText(textBlocks, 18, 30);
-
-    expect(copiedText).toBe('start copying here\nlorem ipsum. stop copying here');
-  });
-
-  it('should copy selected text across > 2 lines', () => {
-    const wrapper = shallow(<QueryField initialQuery="" />);
-    const instance = wrapper.instance() as QueryField;
-    const textBlocks = [
-      'ignore this text. start copying here',
-      'lorem ipsum doler sit amet',
-      'lorem ipsum. stop copying here. lorem ipsum',
-    ];
-    const copiedText = instance.getCopiedText(textBlocks, 18, 30);
-
-    expect(copiedText).toBe('start copying here\nlorem ipsum doler sit amet\nlorem ipsum. stop copying here');
-  });
 });

+ 50 - 557
public/app/features/explore/QueryField.tsx

@@ -1,55 +1,36 @@
 import _ from 'lodash';
 import React, { Context } from 'react';
-import ReactDOM from 'react-dom';
-// @ts-ignore
-import { Change, Range, Value, Block } from 'slate';
-// @ts-ignore
-import { Editor } from 'slate-react';
-// @ts-ignore
+
+import { Value, Editor as CoreEditor } from 'slate';
+import { Editor, Plugin } from '@grafana/slate-react';
 import Plain from 'slate-plain-serializer';
 import classnames from 'classnames';
-// @ts-ignore
-import { isKeyHotkey } from 'is-hotkey';
 
-import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';
+import { CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';
 
 import ClearPlugin from './slate-plugins/clear';
 import NewlinePlugin from './slate-plugins/newline';
+import SelectionShortcutsPlugin from './slate-plugins/selection_shortcuts';
+import IndentationPlugin from './slate-plugins/indentation';
+import ClipboardPlugin from './slate-plugins/clipboard';
+import RunnerPlugin from './slate-plugins/runner';
+import SuggestionsPlugin, { SuggestionsState } from './slate-plugins/suggestions';
 
-import { TypeaheadWithTheme } from './Typeahead';
-import { makeFragment, makeValue } from '@grafana/ui';
+import { Typeahead } from './Typeahead';
 
-export const TYPEAHEAD_DEBOUNCE = 100;
-export const HIGHLIGHT_WAIT = 500;
-const SLATE_TAB = '  ';
-const isIndentLeftHotkey = isKeyHotkey('mod+[');
-const isIndentRightHotkey = isKeyHotkey('mod+]');
-const isSelectLeftHotkey = isKeyHotkey('shift+left');
-const isSelectRightHotkey = isKeyHotkey('shift+right');
-const isSelectUpHotkey = isKeyHotkey('shift+up');
-const isSelectDownHotkey = isKeyHotkey('shift+down');
-const isSelectLineHotkey = isKeyHotkey('mod+l');
-
-function getSuggestionByIndex(suggestions: CompletionItemGroup[], index: number): CompletionItem {
-  // Flatten suggestion groups
-  const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []);
-  const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length;
-  return flattenedSuggestions[correctedIndex];
-}
+import { makeValue, SCHEMA } from '@grafana/ui';
 
-function hasSuggestions(suggestions: CompletionItemGroup[]): boolean {
-  return suggestions && suggestions.length > 0;
-}
+export const HIGHLIGHT_WAIT = 500;
 
 export interface QueryFieldProps {
-  additionalPlugins?: any[];
+  additionalPlugins?: Plugin[];
   cleanText?: (text: string) => string;
   disabled?: boolean;
   initialQuery: string | null;
   onRunQuery?: () => void;
   onChange?: (value: string) => void;
-  onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput;
-  onWillApplySuggestion?: (suggestion: string, state: QueryFieldState) => string;
+  onTypeahead?: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>;
+  onWillApplySuggestion?: (suggestion: string, state: SuggestionsState) => string;
   placeholder?: string;
   portalOrigin?: string;
   syntax?: string;
@@ -59,20 +40,19 @@ export interface QueryFieldProps {
 export interface QueryFieldState {
   suggestions: CompletionItemGroup[];
   typeaheadContext: string | null;
-  typeaheadIndex: number;
   typeaheadPrefix: string;
   typeaheadText: string;
-  value: any;
+  value: Value;
   lastExecutedValue: Value;
 }
 
 export interface TypeaheadInput {
-  editorNode: Element;
   prefix: string;
   selection?: Selection;
   text: string;
   value: Value;
-  wrapperNode: Element;
+  wrapperClasses: string[];
+  labelKey?: string;
 }
 
 /**
@@ -83,23 +63,35 @@ export interface TypeaheadInput {
  */
 export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldState> {
   menuEl: HTMLElement | null;
-  plugins: any[];
-  resetTimer: any;
+  plugins: Plugin[];
+  resetTimer: NodeJS.Timer;
   mounted: boolean;
-  updateHighlightsTimer: any;
+  updateHighlightsTimer: Function;
+  editor: Editor;
+  typeaheadRef: Typeahead;
 
   constructor(props: QueryFieldProps, context: Context<any>) {
     super(props, context);
 
     this.updateHighlightsTimer = _.debounce(this.updateLogsHighlights, HIGHLIGHT_WAIT);
 
+    const { onTypeahead, cleanText, portalOrigin, onWillApplySuggestion } = props;
+
     // Base plugins
-    this.plugins = [ClearPlugin(), NewlinePlugin(), ...(props.additionalPlugins || [])].filter(p => p);
+    this.plugins = [
+      SuggestionsPlugin({ onTypeahead, cleanText, portalOrigin, onWillApplySuggestion, component: this }),
+      ClearPlugin(),
+      RunnerPlugin({ handler: this.executeOnChangeAndRunQueries }),
+      NewlinePlugin(),
+      SelectionShortcutsPlugin(),
+      IndentationPlugin(),
+      ClipboardPlugin(),
+      ...(props.additionalPlugins || []),
+    ].filter(p => p);
 
     this.state = {
       suggestions: [],
       typeaheadContext: null,
-      typeaheadIndex: 0,
       typeaheadPrefix: '',
       typeaheadText: '',
       value: makeValue(props.initialQuery || '', props.syntax),
@@ -109,7 +101,6 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
 
   componentDidMount() {
     this.mounted = true;
-    this.updateMenu();
   }
 
   componentWillUnmount() {
@@ -119,7 +110,7 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
 
   componentDidUpdate(prevProps: QueryFieldProps, prevState: QueryFieldState) {
     const { initialQuery, syntax } = this.props;
-    const { value, suggestions } = this.state;
+    const { value } = this.state;
 
     // if query changed from the outside
     if (initialQuery !== prevProps.initialQuery) {
@@ -128,26 +119,17 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
         this.setState({ value: makeValue(initialQuery || '', syntax) });
       }
     }
-
-    // Only update menu location when suggestion existence or text/selection changed
-    if (value !== prevState.value || hasSuggestions(suggestions) !== hasSuggestions(prevState.suggestions)) {
-      this.updateMenu();
-    }
   }
 
   UNSAFE_componentWillReceiveProps(nextProps: QueryFieldProps) {
     if (nextProps.syntaxLoaded && !this.props.syntaxLoaded) {
       // Need a bogus edit to re-render the editor after syntax has fully loaded
-      const change = this.state.value
-        .change()
-        .insertText(' ')
-        .deleteBackward();
-
-      this.onChange(change, true);
+      const editor = this.editor.insertText(' ').deleteBackward(1);
+      this.onChange(editor.value, true);
     }
   }
 
-  onChange = ({ value }: Change, invokeParentOnValueChanged?: boolean) => {
+  onChange = (value: Value, invokeParentOnValueChanged?: boolean) => {
     const documentChanged = value.document !== this.state.value.document;
     const prevValue = this.state.value;
 
@@ -163,14 +145,6 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
         }
       }
     });
-
-    // Show suggest menu on text input
-    if (documentChanged && value.selection.isCollapsed) {
-      // Need one paint to allow DOM-based typeahead rules to work
-      window.requestAnimationFrame(this.handleTypeahead);
-    } else if (!this.resetTimer) {
-      this.resetTypeahead();
-    }
   };
 
   updateLogsHighlights = () => {
@@ -194,475 +168,18 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
     }
   };
 
-  handleTypeahead = _.debounce(async () => {
-    const selection = window.getSelection();
-    const { cleanText, onTypeahead } = this.props;
-    const { value } = this.state;
-
-    if (onTypeahead && selection.anchorNode) {
-      const wrapperNode = selection.anchorNode.parentElement;
-      const editorNode = wrapperNode.closest('.slate-query-field');
-      if (!editorNode || this.state.value.isBlurred) {
-        // Not inside this editor
-        return;
-      }
-
-      const range = selection.getRangeAt(0);
-      const offset = range.startOffset;
-      const text = selection.anchorNode.textContent;
-      let prefix = text.substr(0, offset);
-
-      // Label values could have valid characters erased if `cleanText()` is
-      // blindly applied, which would undesirably interfere with suggestions
-      const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/);
-      if (labelValueMatch) {
-        prefix = labelValueMatch[1];
-      } else if (cleanText) {
-        prefix = cleanText(prefix);
-      }
-
-      const { suggestions, context, refresher } = onTypeahead({
-        editorNode,
-        prefix,
-        selection,
-        text,
-        value,
-        wrapperNode,
-      });
-
-      let filteredSuggestions = suggestions
-        .map(group => {
-          if (group.items) {
-            if (prefix) {
-              // Filter groups based on prefix
-              if (!group.skipFilter) {
-                group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length);
-                if (group.prefixMatch) {
-                  group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) === 0);
-                } else {
-                  group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) > -1);
-                }
-              }
-              // Filter out the already typed value (prefix) unless it inserts custom text
-              group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix);
-            }
-
-            if (!group.skipSort) {
-              group.items = _.sortBy(group.items, (item: CompletionItem) => item.sortText || item.label);
-            }
-          }
-          return group;
-        })
-        .filter(group => group.items && group.items.length > 0); // Filter out empty groups
-
-      // Keep same object for equality checking later
-      if (_.isEqual(filteredSuggestions, this.state.suggestions)) {
-        filteredSuggestions = this.state.suggestions;
-      }
-
-      this.setState(
-        {
-          suggestions: filteredSuggestions,
-          typeaheadPrefix: prefix,
-          typeaheadContext: context,
-          typeaheadText: text,
-        },
-        () => {
-          if (refresher) {
-            refresher.then(this.handleTypeahead).catch(e => console.error(e));
-          }
-        }
-      );
-    }
-  }, TYPEAHEAD_DEBOUNCE);
-
-  applyTypeahead(change: Change, suggestion: CompletionItem): Change {
-    const { cleanText, onWillApplySuggestion, syntax } = this.props;
-    const { typeaheadPrefix, typeaheadText } = this.state;
-    let suggestionText = suggestion.insertText || suggestion.label;
-    const preserveSuffix = suggestion.kind === 'function';
-    const move = suggestion.move || 0;
-
-    if (onWillApplySuggestion) {
-      suggestionText = onWillApplySuggestion(suggestionText, { ...this.state });
-    }
-
-    this.resetTypeahead();
-
-    // Remove the current, incomplete text and replace it with the selected suggestion
-    const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
-    const text = cleanText ? cleanText(typeaheadText) : typeaheadText;
-    const suffixLength = text.length - typeaheadPrefix.length;
-    const offset = typeaheadText.indexOf(typeaheadPrefix);
-    const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
-    const forward = midWord && !preserveSuffix ? suffixLength + offset : 0;
-
-    // If new-lines, apply suggestion as block
-    if (suggestionText.match(/\n/)) {
-      const fragment = makeFragment(suggestionText, syntax);
-      return change
-        .deleteBackward(backward)
-        .deleteForward(forward)
-        .insertFragment(fragment)
-        .focus();
-    }
-
-    return change
-      .deleteBackward(backward)
-      .deleteForward(forward)
-      .insertText(suggestionText)
-      .move(move)
-      .focus();
-  }
-
-  handleEnterKey = (event: KeyboardEvent, change: Change) => {
-    event.preventDefault();
-
-    if (event.shiftKey) {
-      // pass through if shift is pressed
-      return undefined;
-    } else if (!this.menuEl) {
-      this.executeOnChangeAndRunQueries();
-      return true;
-    } else {
-      return this.selectSuggestion(change);
-    }
-  };
-
-  selectSuggestion = (change: Change) => {
-    const { typeaheadIndex, suggestions } = this.state;
-    event.preventDefault();
-
-    if (!suggestions || suggestions.length === 0) {
-      return undefined;
-    }
-
-    const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex);
-    const nextChange = this.applyTypeahead(change, suggestion);
-
-    const insertTextOperation = nextChange.operations.find((operation: any) => operation.type === 'insert_text');
-    return insertTextOperation ? true : undefined;
-  };
-
-  handleTabKey = (change: Change): void => {
-    const {
-      startBlock,
-      endBlock,
-      selection: { startOffset, startKey, endOffset, endKey },
-    } = change.value;
-
-    if (this.menuEl) {
-      this.selectSuggestion(change);
-      return;
-    }
-
-    const first = startBlock.getFirstText();
-
-    const startBlockIsSelected =
-      startOffset === 0 && startKey === first.key && endOffset === first.text.length && endKey === first.key;
-
-    if (startBlockIsSelected || !startBlock.equals(endBlock)) {
-      this.handleIndent(change, 'right');
-    } else {
-      change.insertText(SLATE_TAB);
-    }
-  };
-
-  handleIndent = (change: Change, indentDirection: 'left' | 'right') => {
-    const curSelection = change.value.selection;
-    const selectedBlocks = change.value.document.getBlocksAtRange(curSelection);
-
-    if (indentDirection === 'left') {
-      for (const block of selectedBlocks) {
-        const blockWhitespace = block.text.length - block.text.trimLeft().length;
-
-        const rangeProperties = {
-          anchorKey: block.getFirstText().key,
-          anchorOffset: blockWhitespace,
-          focusKey: block.getFirstText().key,
-          focusOffset: blockWhitespace,
-        };
-
-        // @ts-ignore
-        const whitespaceToDelete = Range.create(rangeProperties);
-
-        change.deleteBackwardAtRange(whitespaceToDelete, Math.min(SLATE_TAB.length, blockWhitespace));
-      }
-    } else {
-      const { startText } = change.value;
-      const textBeforeCaret = startText.text.slice(0, curSelection.startOffset);
-      const isWhiteSpace = /^\s*$/.test(textBeforeCaret);
-
-      for (const block of selectedBlocks) {
-        change.insertTextByKey(block.getFirstText().key, 0, SLATE_TAB);
-      }
-
-      if (isWhiteSpace) {
-        change.moveStart(-SLATE_TAB.length);
-      }
-    }
-  };
-
-  handleSelectVertical = (change: Change, direction: 'up' | 'down') => {
-    const { focusBlock } = change.value;
-    const adjacentBlock =
-      direction === 'up'
-        ? change.value.document.getPreviousBlock(focusBlock.key)
-        : change.value.document.getNextBlock(focusBlock.key);
-
-    if (!adjacentBlock) {
-      return true;
-    }
-    const adjacentText = adjacentBlock.getFirstText();
-    change.moveFocusTo(adjacentText.key, Math.min(change.value.anchorOffset, adjacentText.text.length)).focus();
-    return true;
-  };
-
-  handleSelectUp = (change: Change) => this.handleSelectVertical(change, 'up');
-
-  handleSelectDown = (change: Change) => this.handleSelectVertical(change, 'down');
-
-  onKeyDown = (event: KeyboardEvent, change: Change) => {
-    const { typeaheadIndex } = this.state;
-
-    // Shortcuts
-    if (isIndentLeftHotkey(event)) {
-      event.preventDefault();
-      this.handleIndent(change, 'left');
-      return true;
-    } else if (isIndentRightHotkey(event)) {
-      event.preventDefault();
-      this.handleIndent(change, 'right');
-      return true;
-    } else if (isSelectLeftHotkey(event)) {
-      event.preventDefault();
-      if (change.value.focusOffset > 0) {
-        change.moveFocus(-1);
-      }
-      return true;
-    } else if (isSelectRightHotkey(event)) {
-      event.preventDefault();
-      if (change.value.focusOffset < change.value.startText.text.length) {
-        change.moveFocus(1);
-      }
-      return true;
-    } else if (isSelectUpHotkey(event)) {
-      event.preventDefault();
-      this.handleSelectUp(change);
-      return true;
-    } else if (isSelectDownHotkey(event)) {
-      event.preventDefault();
-      this.handleSelectDown(change);
-      return true;
-    } else if (isSelectLineHotkey(event)) {
-      event.preventDefault();
-      const { focusBlock, document } = change.value;
-
-      change.moveAnchorToStartOfBlock(focusBlock.key);
-
-      const nextBlock = document.getNextBlock(focusBlock.key);
-      if (nextBlock) {
-        change.moveFocusToStartOfNextBlock();
-      } else {
-        change.moveFocusToEndOfText();
-      }
-
-      return true;
-    }
-
-    switch (event.key) {
-      case 'Escape': {
-        if (this.menuEl) {
-          event.preventDefault();
-          event.stopPropagation();
-          this.resetTypeahead();
-          return true;
-        }
-        break;
-      }
-
-      case ' ': {
-        if (event.ctrlKey) {
-          event.preventDefault();
-          this.handleTypeahead();
-          return true;
-        }
-        break;
-      }
-
-      case 'Enter':
-        return this.handleEnterKey(event, change);
-
-      case 'Tab': {
-        event.preventDefault();
-        return this.handleTabKey(change);
-      }
-
-      case 'ArrowDown': {
-        if (this.menuEl) {
-          // Select next suggestion
-          event.preventDefault();
-          const itemsCount =
-            this.state.suggestions.length > 0
-              ? this.state.suggestions.reduce((totalCount, current) => totalCount + current.items.length, 0)
-              : 0;
-          this.setState({ typeaheadIndex: Math.min(itemsCount - 1, typeaheadIndex + 1) });
-        }
-        break;
-      }
-
-      case 'ArrowUp': {
-        if (this.menuEl) {
-          // Select previous suggestion
-          event.preventDefault();
-          this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) });
-        }
-        break;
-      }
-
-      default: {
-        // console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
-        break;
-      }
-    }
-    return undefined;
-  };
-
-  resetTypeahead = () => {
-    if (this.mounted) {
-      this.setState({ suggestions: [], typeaheadIndex: 0, typeaheadPrefix: '', typeaheadContext: null });
-      this.resetTimer = null;
-    }
-  };
-
-  handleBlur = (event: FocusEvent, change: Change) => {
+  handleBlur = (event: Event, editor: CoreEditor, next: Function) => {
     const { lastExecutedValue } = this.state;
     const previousValue = lastExecutedValue ? Plain.serialize(this.state.lastExecutedValue) : null;
-    const currentValue = Plain.serialize(change.value);
-
-    // If we dont wait here, menu clicks wont work because the menu
-    // will be gone.
-    this.resetTimer = setTimeout(this.resetTypeahead, 100);
+    const currentValue = Plain.serialize(editor.value);
 
     if (previousValue !== currentValue) {
       this.executeOnChangeAndRunQueries();
     }
-  };
-
-  onClickMenu = (item: CompletionItem) => {
-    // Manually triggering change
-    const change = this.applyTypeahead(this.state.value.change(), item);
-    this.onChange(change, true);
-  };
-
-  updateMenu = () => {
-    const { suggestions } = this.state;
-    const menu = this.menuEl;
-    // Exit for unit tests
-    if (!window.getSelection) {
-      return;
-    }
-    const selection = window.getSelection();
-    const node = selection.anchorNode;
-
-    // No menu, nothing to do
-    if (!menu) {
-      return;
-    }
-
-    // No suggestions or blur, remove menu
-    if (!hasSuggestions(suggestions)) {
-      menu.removeAttribute('style');
-      return;
-    }
-
-    // Align menu overlay to editor node
-    if (node) {
-      // Read from DOM
-      const rect = node.parentElement.getBoundingClientRect();
-      const scrollX = window.scrollX;
-      const scrollY = window.scrollY;
-
-      // Write DOM
-      requestAnimationFrame(() => {
-        menu.style.opacity = '1';
-        menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
-        menu.style.left = `${rect.left + scrollX - 2}px`;
-      });
-    }
-  };
-
-  menuRef = (el: HTMLElement) => {
-    this.menuEl = el;
-  };
-
-  renderMenu = () => {
-    const { portalOrigin } = this.props;
-    const { suggestions, typeaheadIndex, typeaheadPrefix } = this.state;
-    if (!hasSuggestions(suggestions)) {
-      return null;
-    }
-
-    const selectedItem = getSuggestionByIndex(suggestions, typeaheadIndex);
-
-    // Create typeahead in DOM root so we can later position it absolutely
-    return (
-      <Portal origin={portalOrigin}>
-        <TypeaheadWithTheme
-          menuRef={this.menuRef}
-          selectedItem={selectedItem}
-          onClickItem={this.onClickMenu}
-          prefix={typeaheadPrefix}
-          groupedItems={suggestions}
-          typeaheadIndex={typeaheadIndex}
-        />
-      </Portal>
-    );
-  };
-
-  getCopiedText(textBlocks: string[], startOffset: number, endOffset: number) {
-    if (!textBlocks.length) {
-      return undefined;
-    }
-
-    const excludingLastLineLength = textBlocks.slice(0, -1).join('').length + textBlocks.length - 1;
-    return textBlocks.join('\n').slice(startOffset, excludingLastLineLength + endOffset);
-  }
-
-  handleCopy = (event: ClipboardEvent, change: Change) => {
-    event.preventDefault();
-
-    const { document, selection, startOffset, endOffset } = change.value;
-    const selectedBlocks = document.getBlocksAtRangeAsArray(selection).map((block: Block) => block.text);
-
-    const copiedText = this.getCopiedText(selectedBlocks, startOffset, endOffset);
-    if (copiedText) {
-      event.clipboardData.setData('Text', copiedText);
-    }
-
-    return true;
-  };
-
-  handlePaste = (event: ClipboardEvent, change: Change) => {
-    event.preventDefault();
-    const pastedValue = event.clipboardData.getData('Text');
-    const lines = pastedValue.split('\n');
-
-    if (lines.length) {
-      change.insertText(lines[0]);
-      for (const line of lines.slice(1)) {
-        change.splitBlock().insertText(line);
-      }
-    }
-
-    return true;
-  };
 
-  handleCut = (event: ClipboardEvent, change: Change) => {
-    this.handleCopy(event, change);
-    change.deleteAtRange(change.value.selection);
+    editor.blur();
 
-    return true;
+    return next();
   };
 
   render() {
@@ -670,19 +187,20 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
     const wrapperClassName = classnames('slate-query-field__wrapper', {
       'slate-query-field__wrapper--disabled': disabled,
     });
+
     return (
       <div className={wrapperClassName}>
         <div className="slate-query-field">
-          {this.renderMenu()}
           <Editor
+            ref={editor => (this.editor = editor)}
+            schema={SCHEMA}
             autoCorrect={false}
             readOnly={this.props.disabled}
             onBlur={this.handleBlur}
-            onKeyDown={this.onKeyDown}
-            onChange={this.onChange}
-            onCopy={this.handleCopy}
-            onPaste={this.handlePaste}
-            onCut={this.handleCut}
+            // onKeyDown={this.onKeyDown}
+            onChange={(change: { value: Value }) => {
+              this.onChange(change.value, false);
+            }}
             placeholder={this.props.placeholder}
             plugins={this.plugins}
             spellCheck={false}
@@ -694,29 +212,4 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
   }
 }
 
-interface PortalProps {
-  index?: number;
-  origin: string;
-}
-
-class Portal extends React.PureComponent<PortalProps, {}> {
-  node: HTMLElement;
-
-  constructor(props: PortalProps) {
-    super(props);
-    const { index = 0, origin = 'query' } = props;
-    this.node = document.createElement('div');
-    this.node.classList.add(`slate-typeahead`, `slate-typeahead-${origin}-${index}`);
-    document.body.appendChild(this.node);
-  }
-
-  componentWillUnmount() {
-    document.body.removeChild(this.node);
-  }
-
-  render() {
-    return ReactDOM.createPortal(this.props.children, this.node);
-  }
-}
-
 export default QueryField;

+ 152 - 72
public/app/features/explore/Typeahead.tsx

@@ -1,21 +1,24 @@
-import React, { createRef } from 'react';
+import React, { createRef, CSSProperties } from 'react';
+import ReactDOM from 'react-dom';
 import _ from 'lodash';
 import { FixedSizeList } from 'react-window';
 
 import { Themeable, withTheme } from '@grafana/ui';
 
-import { CompletionItem, CompletionItemGroup } from 'app/types/explore';
+import { CompletionItem, CompletionItemKind, CompletionItemGroup } from 'app/types/explore';
 import { TypeaheadItem } from './TypeaheadItem';
 import { TypeaheadInfo } from './TypeaheadInfo';
 import { flattenGroupItems, calculateLongestLabel, calculateListSizes } from './utils/typeahead';
 
+const modulo = (a: number, n: number) => a - n * Math.floor(a / n);
+
 interface Props extends Themeable {
+  origin: string;
   groupedItems: CompletionItemGroup[];
-  menuRef: any;
-  selectedItem: CompletionItem | null;
-  onClickItem: (suggestion: CompletionItem) => void;
   prefix?: string;
-  typeaheadIndex: number;
+  menuRef?: (el: Typeahead) => void;
+  onSelectSuggestion?: (suggestion: CompletionItem) => void;
+  isOpen?: boolean;
 }
 
 interface State {
@@ -23,11 +26,12 @@ interface State {
   listWidth: number;
   listHeight: number;
   itemHeight: number;
+  hoveredItem: number;
+  typeaheadIndex: number;
 }
 
 export class Typeahead extends React.PureComponent<Props, State> {
-  listRef: any = createRef();
-  documentationRef: any = createRef();
+  listRef = createRef<FixedSizeList>();
 
   constructor(props: Props) {
     super(props);
@@ -35,97 +39,173 @@ export class Typeahead extends React.PureComponent<Props, State> {
     const allItems = flattenGroupItems(props.groupedItems);
     const longestLabel = calculateLongestLabel(allItems);
     const { listWidth, listHeight, itemHeight } = calculateListSizes(props.theme, allItems, longestLabel);
-    this.state = { listWidth, listHeight, itemHeight, allItems };
+    this.state = { listWidth, listHeight, itemHeight, hoveredItem: null, typeaheadIndex: 1, allItems };
   }
 
-  componentDidUpdate = (prevProps: Readonly<Props>) => {
-    if (prevProps.typeaheadIndex !== this.props.typeaheadIndex && this.listRef && this.listRef.current) {
-      if (prevProps.typeaheadIndex === 1 && this.props.typeaheadIndex === 0) {
+  componentDidMount = () => {
+    this.props.menuRef(this);
+  };
+
+  componentDidUpdate = (prevProps: Readonly<Props>, prevState: Readonly<State>) => {
+    if (prevState.typeaheadIndex !== this.state.typeaheadIndex && this.listRef && this.listRef.current) {
+      if (this.state.typeaheadIndex === 1) {
         this.listRef.current.scrollToItem(0); // special case for handling the first group label
-        this.refreshDocumentation();
         return;
       }
-      const index = this.state.allItems.findIndex(item => item === this.props.selectedItem);
-      this.listRef.current.scrollToItem(index);
-      this.refreshDocumentation();
+      this.listRef.current.scrollToItem(this.state.typeaheadIndex);
     }
 
     if (_.isEqual(prevProps.groupedItems, this.props.groupedItems) === false) {
       const allItems = flattenGroupItems(this.props.groupedItems);
       const longestLabel = calculateLongestLabel(allItems);
       const { listWidth, listHeight, itemHeight } = calculateListSizes(this.props.theme, allItems, longestLabel);
-      this.setState({ listWidth, listHeight, itemHeight, allItems }, () => this.refreshDocumentation());
+      this.setState({ listWidth, listHeight, itemHeight, allItems });
     }
   };
 
-  refreshDocumentation = () => {
-    if (!this.documentationRef.current) {
-      return;
-    }
+  onMouseEnter = (index: number) => {
+    this.setState({
+      hoveredItem: index,
+    });
+  };
+
+  onMouseLeave = () => {
+    this.setState({
+      hoveredItem: null,
+    });
+  };
+
+  moveMenuIndex = (moveAmount: number) => {
+    const itemCount = this.state.allItems.length;
+    if (itemCount) {
+      // Select next suggestion
+      event.preventDefault();
+      let newTypeaheadIndex = modulo(this.state.typeaheadIndex + moveAmount, itemCount);
 
-    const index = this.state.allItems.findIndex(item => item === this.props.selectedItem);
-    const item = this.state.allItems[index];
+      if (this.state.allItems[newTypeaheadIndex].kind === CompletionItemKind.GroupTitle) {
+        newTypeaheadIndex = modulo(newTypeaheadIndex + moveAmount, itemCount);
+      }
 
-    if (item) {
-      this.documentationRef.current.refresh(item);
+      this.setState({
+        typeaheadIndex: newTypeaheadIndex,
+      });
+
+      return;
     }
   };
 
-  onMouseEnter = (item: CompletionItem) => {
-    this.documentationRef.current.refresh(item);
+  insertSuggestion = () => {
+    this.props.onSelectSuggestion(this.state.allItems[this.state.typeaheadIndex]);
   };
 
-  onMouseLeave = () => {
-    this.documentationRef.current.hide();
-  };
+  get menuPosition(): CSSProperties {
+    // Exit for unit tests
+    if (!window.getSelection) {
+      return {};
+    }
+
+    const selection = window.getSelection();
+    const node = selection.anchorNode;
+
+    // Align menu overlay to editor node
+    if (node) {
+      // Read from DOM
+      const rect = node.parentElement.getBoundingClientRect();
+      const scrollX = window.scrollX;
+      const scrollY = window.scrollY;
+
+      return {
+        top: `${rect.top + scrollY + rect.height + 4}px`,
+        left: `${rect.left + scrollX - 2}px`,
+      };
+    }
+
+    return {};
+  }
 
   render() {
-    const { menuRef, selectedItem, onClickItem, prefix, theme } = this.props;
-    const { listWidth, listHeight, itemHeight, allItems } = this.state;
+    const { prefix, theme, isOpen, origin } = this.props;
+    const { allItems, listWidth, listHeight, itemHeight, hoveredItem, typeaheadIndex } = this.state;
+
+    const showDocumentation = hoveredItem || typeaheadIndex;
 
     return (
-      <ul className="typeahead" ref={menuRef}>
-        <TypeaheadInfo
-          ref={this.documentationRef}
-          width={listWidth}
-          height={listHeight}
-          theme={theme}
-          initialItem={selectedItem}
-        />
-        <FixedSizeList
-          ref={this.listRef}
-          itemCount={allItems.length}
-          itemSize={itemHeight}
-          itemKey={index => {
-            const item = allItems && allItems[index];
-            const key = item ? `${index}-${item.label}` : `${index}`;
-            return key;
-          }}
-          width={listWidth}
-          height={listHeight}
-        >
-          {({ index, style }) => {
-            const item = allItems && allItems[index];
-            if (!item) {
-              return null;
-            }
-
-            return (
-              <TypeaheadItem
-                onClickItem={onClickItem}
-                isSelected={selectedItem === item}
-                item={item}
-                prefix={prefix}
-                style={style}
-                onMouseEnter={this.onMouseEnter}
-                onMouseLeave={this.onMouseLeave}
-              />
-            );
-          }}
-        </FixedSizeList>
-      </ul>
+      <Portal origin={origin} isOpen={isOpen}>
+        <ul className="typeahead" style={this.menuPosition}>
+          <FixedSizeList
+            ref={this.listRef}
+            itemCount={allItems.length}
+            itemSize={itemHeight}
+            itemKey={index => {
+              const item = allItems && allItems[index];
+              const key = item ? `${index}-${item.label}` : `${index}`;
+              return key;
+            }}
+            width={listWidth}
+            height={listHeight}
+          >
+            {({ index, style }) => {
+              const item = allItems && allItems[index];
+              if (!item) {
+                return null;
+              }
+
+              return (
+                <TypeaheadItem
+                  onClickItem={() => this.props.onSelectSuggestion(item)}
+                  isSelected={allItems[typeaheadIndex] === item}
+                  item={item}
+                  prefix={prefix}
+                  style={style}
+                  onMouseEnter={() => this.onMouseEnter(index)}
+                  onMouseLeave={this.onMouseLeave}
+                />
+              );
+            }}
+          </FixedSizeList>
+        </ul>
+
+        {showDocumentation && (
+          <TypeaheadInfo
+            width={listWidth}
+            height={listHeight}
+            theme={theme}
+            item={allItems[hoveredItem ? hoveredItem : typeaheadIndex]}
+          />
+        )}
+      </Portal>
     );
   }
 }
 
 export const TypeaheadWithTheme = withTheme(Typeahead);
+
+interface PortalProps {
+  index?: number;
+  isOpen: boolean;
+  origin: string;
+}
+
+class Portal extends React.PureComponent<PortalProps, {}> {
+  node: HTMLElement;
+
+  constructor(props: PortalProps) {
+    super(props);
+    const { index = 0, origin = 'query' } = props;
+    this.node = document.createElement('div');
+    this.node.classList.add(`slate-typeahead`, `slate-typeahead-${origin}-${index}`);
+    document.body.appendChild(this.node);
+  }
+
+  componentWillUnmount() {
+    document.body.removeChild(this.node);
+  }
+
+  render() {
+    if (this.props.isOpen) {
+      return ReactDOM.createPortal(this.props.children, this.node);
+    }
+
+    return null;
+  }
+}

+ 7 - 18
public/app/features/explore/TypeaheadInfo.tsx

@@ -1,29 +1,26 @@
 import React, { PureComponent } from 'react';
-import { Themeable, selectThemeVariant } from '@grafana/ui';
 import { css, cx } from 'emotion';
 
+import { Themeable, selectThemeVariant } from '@grafana/ui';
+
 import { CompletionItem } from 'app/types/explore';
 
 interface Props extends Themeable {
-  initialItem: CompletionItem;
+  item: CompletionItem;
   width: number;
   height: number;
 }
 
-interface State {
-  item: CompletionItem;
-}
-
-export class TypeaheadInfo extends PureComponent<Props, State> {
+export class TypeaheadInfo extends PureComponent<Props> {
   constructor(props: Props) {
     super(props);
-    this.state = { item: props.initialItem };
   }
 
   getStyles = (visible: boolean) => {
     const { width, height, theme } = this.props;
     const selection = window.getSelection();
     const node = selection.anchorNode;
+
     if (!node) {
       return {};
     }
@@ -38,7 +35,7 @@ export class TypeaheadInfo extends PureComponent<Props, State> {
     return {
       typeaheadItem: css`
         label: type-ahead-item;
-        z-index: auto;
+        z-index: 500;
         padding: ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.md};
         border-radius: ${theme.border.radius.md};
         border: ${selectThemeVariant(
@@ -64,16 +61,8 @@ export class TypeaheadInfo extends PureComponent<Props, State> {
     };
   };
 
-  refresh = (item: CompletionItem) => {
-    this.setState({ item });
-  };
-
-  hide = () => {
-    this.setState({ item: null });
-  };
-
   render() {
-    const { item } = this.state;
+    const { item } = this.props;
     const visible = item && !!item.documentation;
     const label = item ? item.label : '';
     const documentation = item && item.documentation ? item.documentation : '';

+ 19 - 17
public/app/features/explore/TypeaheadItem.tsx

@@ -1,25 +1,21 @@
 import React, { FunctionComponent, useContext } from 'react';
+
 // @ts-ignore
 import Highlighter from 'react-highlight-words';
 import { css, cx } from 'emotion';
 import { GrafanaTheme, ThemeContext, selectThemeVariant } from '@grafana/ui';
 
-import { CompletionItem } from 'app/types/explore';
-
-export const GROUP_TITLE_KIND = 'GroupTitle';
-
-export const isGroupTitle = (item: CompletionItem) => {
-  return item.kind && item.kind === GROUP_TITLE_KIND ? true : false;
-};
+import { CompletionItem, CompletionItemKind } from 'app/types/explore';
 
 interface Props {
   isSelected: boolean;
   item: CompletionItem;
-  onClickItem: (suggestion: CompletionItem) => void;
-  prefix?: string;
   style: any;
-  onMouseEnter: (item: CompletionItem) => void;
-  onMouseLeave: (item: CompletionItem) => void;
+  prefix?: string;
+
+  onClickItem?: (event: React.MouseEvent) => void;
+  onMouseEnter?: () => void;
+  onMouseLeave?: () => void;
 }
 
 const getStyles = (theme: GrafanaTheme) => ({
@@ -38,10 +34,12 @@ const getStyles = (theme: GrafanaTheme) => ({
     transition: color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
       background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.15s cubic-bezier(0.645, 0.045, 0.355, 1);
   `,
+
   typeaheadItemSelected: css`
     label: type-ahead-item-selected;
     background-color: ${selectThemeVariant({ light: theme.colors.gray6, dark: theme.colors.dark9 }, theme.type)};
   `,
+
   typeaheadItemMatch: css`
     label: type-ahead-item-match;
     color: ${theme.colors.yellow};
@@ -49,6 +47,7 @@ const getStyles = (theme: GrafanaTheme) => ({
     padding: inherit;
     background: inherit;
   `,
+
   typeaheadItemGroupTitle: css`
     label: type-ahead-item-group-title;
     color: ${theme.colors.textWeak};
@@ -62,16 +61,13 @@ export const TypeaheadItem: FunctionComponent<Props> = (props: Props) => {
   const theme = useContext(ThemeContext);
   const styles = getStyles(theme);
 
-  const { isSelected, item, prefix, style, onClickItem } = props;
-  const onClick = () => onClickItem(item);
-  const onMouseEnter = () => props.onMouseEnter(item);
-  const onMouseLeave = () => props.onMouseLeave(item);
+  const { isSelected, item, prefix, style, onMouseEnter, onMouseLeave, onClickItem } = props;
   const className = isSelected ? cx([styles.typeaheadItem, styles.typeaheadItemSelected]) : cx([styles.typeaheadItem]);
   const highlightClassName = cx([styles.typeaheadItemMatch]);
   const itemGroupTitleClassName = cx([styles.typeaheadItemGroupTitle]);
   const label = item.label || '';
 
-  if (isGroupTitle(item)) {
+  if (item.kind === CompletionItemKind.GroupTitle) {
     return (
       <li className={itemGroupTitleClassName} style={style}>
         <span>{label}</span>
@@ -80,7 +76,13 @@ export const TypeaheadItem: FunctionComponent<Props> = (props: Props) => {
   }
 
   return (
-    <li className={className} onClick={onClick} style={style} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
+    <li
+      className={className}
+      style={style}
+      onMouseDown={onClickItem}
+      onMouseEnter={onMouseEnter}
+      onMouseLeave={onMouseLeave}
+    >
       <Highlighter textToHighlight={label} searchWords={[prefix]} highlightClassName={highlightClassName} />
     </li>
   );

+ 0 - 39
public/app/features/explore/slate-plugins/braces.test.ts

@@ -1,39 +0,0 @@
-// @ts-ignore
-import Plain from 'slate-plain-serializer';
-
-import BracesPlugin from './braces';
-
-declare global {
-  interface Window {
-    KeyboardEvent: any;
-  }
-}
-
-describe('braces', () => {
-  const handler = BracesPlugin().onKeyDown;
-
-  it('adds closing braces around empty value', () => {
-    const change = Plain.deserialize('').change();
-    const event = new window.KeyboardEvent('keydown', { key: '(' });
-    handler(event, change);
-    expect(Plain.serialize(change.value)).toEqual('()');
-  });
-
-  it('removes closing brace when opening brace is removed', () => {
-    const change = Plain.deserialize('time()').change();
-    let event;
-    change.move(5);
-    event = new window.KeyboardEvent('keydown', { key: 'Backspace' });
-    handler(event, change);
-    expect(Plain.serialize(change.value)).toEqual('time');
-  });
-
-  it('keeps closing brace when opening brace is removed and inner values exist', () => {
-    const change = Plain.deserialize('time(value)').change();
-    let event;
-    change.move(5);
-    event = new window.KeyboardEvent('keydown', { key: 'Backspace' });
-    const handled = handler(event, change);
-    expect(handled).toBeFalsy();
-  });
-});

+ 40 - 0
public/app/features/explore/slate-plugins/braces.test.tsx

@@ -0,0 +1,40 @@
+import React from 'react';
+import Plain from 'slate-plain-serializer';
+import { Editor } from '@grafana/slate-react';
+import { shallow } from 'enzyme';
+import BracesPlugin from './braces';
+
+declare global {
+  interface Window {
+    KeyboardEvent: any;
+  }
+}
+
+describe('braces', () => {
+  const handler = BracesPlugin().onKeyDown;
+  const nextMock = () => {};
+
+  it('adds closing braces around empty value', () => {
+    const value = Plain.deserialize('');
+    const editor = shallow<Editor>(<Editor value={value} />);
+    const event = new window.KeyboardEvent('keydown', { key: '(' });
+    handler(event as Event, editor.instance() as any, nextMock);
+    expect(Plain.serialize(editor.instance().value)).toEqual('()');
+  });
+
+  it('removes closing brace when opening brace is removed', () => {
+    const value = Plain.deserialize('time()');
+    const editor = shallow<Editor>(<Editor value={value} />);
+    const event = new window.KeyboardEvent('keydown', { key: 'Backspace' });
+    handler(event as Event, editor.instance().moveForward(5) as any, nextMock);
+    expect(Plain.serialize(editor.instance().value)).toEqual('time');
+  });
+
+  it('keeps closing brace when opening brace is removed and inner values exist', () => {
+    const value = Plain.deserialize('time(value)');
+    const editor = shallow<Editor>(<Editor value={value} />);
+    const event = new window.KeyboardEvent('keydown', { key: 'Backspace' });
+    const handled = handler(event as Event, editor.instance().moveForward(5) as any, nextMock);
+    expect(handled).toBeFalsy();
+  });
+});

+ 22 - 18
public/app/features/explore/slate-plugins/braces.ts

@@ -1,5 +1,5 @@
-// @ts-ignore
-import { Change } from 'slate';
+import { Plugin } from '@grafana/slate-react';
+import { Editor as CoreEditor } from 'slate';
 
 const BRACES: any = {
   '[': ']',
@@ -7,34 +7,37 @@ const BRACES: any = {
   '(': ')',
 };
 
-export default function BracesPlugin() {
+export default function BracesPlugin(): Plugin {
   return {
-    onKeyDown(event: KeyboardEvent, change: Change) {
-      const { value } = change;
+    onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) {
+      const { value } = editor;
 
       switch (event.key) {
         case '(':
         case '{':
         case '[': {
           event.preventDefault();
-
-          const { startOffset, startKey, endOffset, endKey, focusOffset } = value.selection;
-          const text: string = value.focusText.text;
+          const {
+            start: { offset: startOffset, key: startKey },
+            end: { offset: endOffset, key: endKey },
+            focus: { offset: focusOffset },
+          } = value.selection;
+          const text = value.focusText.text;
 
           // If text is selected, wrap selected text in parens
-          if (value.isExpanded) {
-            change
+          if (value.selection.isExpanded) {
+            editor
               .insertTextByKey(startKey, startOffset, event.key)
               .insertTextByKey(endKey, endOffset + 1, BRACES[event.key])
-              .moveEnd(-1);
+              .moveEndBackward(1);
           } else if (
             focusOffset === text.length ||
             text[focusOffset] === ' ' ||
             Object.values(BRACES).includes(text[focusOffset])
           ) {
-            change.insertText(`${event.key}${BRACES[event.key]}`).move(-1);
+            editor.insertText(`${event.key}${BRACES[event.key]}`).moveBackward(1);
           } else {
-            change.insertText(event.key);
+            editor.insertText(event.key);
           }
 
           return true;
@@ -42,15 +45,15 @@ export default function BracesPlugin() {
 
         case 'Backspace': {
           const text = value.anchorText.text;
-          const offset = value.anchorOffset;
+          const offset = value.selection.anchor.offset;
           const previousChar = text[offset - 1];
           const nextChar = text[offset];
           if (BRACES[previousChar] && BRACES[previousChar] === nextChar) {
             event.preventDefault();
             // Remove closing brace if directly following
-            change
-              .deleteBackward()
-              .deleteForward()
+            editor
+              .deleteBackward(1)
+              .deleteForward(1)
               .focus();
             return true;
           }
@@ -60,7 +63,8 @@ export default function BracesPlugin() {
           break;
         }
       }
-      return undefined;
+
+      return next();
     },
   };
 }

+ 0 - 39
public/app/features/explore/slate-plugins/clear.test.ts

@@ -1,39 +0,0 @@
-// @ts-ignore
-import Plain from 'slate-plain-serializer';
-
-import ClearPlugin from './clear';
-
-describe('clear', () => {
-  const handler = ClearPlugin().onKeyDown;
-
-  it('does not change the empty value', () => {
-    const change = Plain.deserialize('').change();
-    const event = new window.KeyboardEvent('keydown', {
-      key: 'k',
-      ctrlKey: true,
-    });
-    handler(event, change);
-    expect(Plain.serialize(change.value)).toEqual('');
-  });
-
-  it('clears to the end of the line', () => {
-    const change = Plain.deserialize('foo').change();
-    const event = new window.KeyboardEvent('keydown', {
-      key: 'k',
-      ctrlKey: true,
-    });
-    handler(event, change);
-    expect(Plain.serialize(change.value)).toEqual('');
-  });
-
-  it('clears from the middle to the end of the line', () => {
-    const change = Plain.deserialize('foo bar').change();
-    change.move(4);
-    const event = new window.KeyboardEvent('keydown', {
-      key: 'k',
-      ctrlKey: true,
-    });
-    handler(event, change);
-    expect(Plain.serialize(change.value)).toEqual('foo ');
-  });
-});

+ 42 - 0
public/app/features/explore/slate-plugins/clear.test.tsx

@@ -0,0 +1,42 @@
+import Plain from 'slate-plain-serializer';
+import React from 'react';
+import { Editor } from '@grafana/slate-react';
+import { shallow } from 'enzyme';
+import ClearPlugin from './clear';
+
+describe('clear', () => {
+  const handler = ClearPlugin().onKeyDown;
+
+  it('does not change the empty value', () => {
+    const value = Plain.deserialize('');
+    const editor = shallow<Editor>(<Editor value={value} />);
+    const event = new window.KeyboardEvent('keydown', {
+      key: 'k',
+      ctrlKey: true,
+    });
+    handler(event as Event, editor.instance() as any, () => {});
+    expect(Plain.serialize(editor.instance().value)).toEqual('');
+  });
+
+  it('clears to the end of the line', () => {
+    const value = Plain.deserialize('foo');
+    const editor = shallow<Editor>(<Editor value={value} />);
+    const event = new window.KeyboardEvent('keydown', {
+      key: 'k',
+      ctrlKey: true,
+    });
+    handler(event as Event, editor.instance() as any, () => {});
+    expect(Plain.serialize(editor.instance().value)).toEqual('');
+  });
+
+  it('clears from the middle to the end of the line', () => {
+    const value = Plain.deserialize('foo bar');
+    const editor = shallow<Editor>(<Editor value={value} />);
+    const event = new window.KeyboardEvent('keydown', {
+      key: 'k',
+      ctrlKey: true,
+    });
+    handler(event as Event, editor.instance().moveForward(4) as any, () => {});
+    expect(Plain.serialize(editor.instance().value)).toEqual('foo ');
+  });
+});

+ 13 - 8
public/app/features/explore/slate-plugins/clear.ts

@@ -1,22 +1,27 @@
+import { Plugin } from '@grafana/slate-react';
+import { Editor as CoreEditor } from 'slate';
+
 // Clears the rest of the line after the caret
-export default function ClearPlugin() {
+export default function ClearPlugin(): Plugin {
   return {
-    onKeyDown(event: any, change: { value?: any; deleteForward?: any }) {
-      const { value } = change;
-      if (!value.isCollapsed) {
-        return undefined;
+    onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) {
+      const value = editor.value;
+
+      if (value.selection.isExpanded) {
+        return next();
       }
 
       if (event.key === 'k' && event.ctrlKey) {
         event.preventDefault();
         const text = value.anchorText.text;
-        const offset = value.anchorOffset;
+        const offset = value.selection.anchor.offset;
         const length = text.length;
         const forward = length - offset;
-        change.deleteForward(forward);
+        editor.deleteForward(forward);
         return true;
       }
-      return undefined;
+
+      return next();
     },
   };
 }

+ 61 - 0
public/app/features/explore/slate-plugins/clipboard.ts

@@ -0,0 +1,61 @@
+import { Plugin } from '@grafana/slate-react';
+import { Editor as CoreEditor } from 'slate';
+
+const getCopiedText = (textBlocks: string[], startOffset: number, endOffset: number) => {
+  if (!textBlocks.length) {
+    return undefined;
+  }
+
+  const excludingLastLineLength = textBlocks.slice(0, -1).join('').length + textBlocks.length - 1;
+  return textBlocks.join('\n').slice(startOffset, excludingLastLineLength + endOffset);
+};
+
+export default function ClipboardPlugin(): Plugin {
+  const clipboardPlugin = {
+    onCopy(event: ClipboardEvent, editor: CoreEditor) {
+      event.preventDefault();
+
+      const { document, selection } = editor.value;
+      const {
+        start: { offset: startOffset },
+        end: { offset: endOffset },
+      } = selection;
+      const selectedBlocks = document
+        .getLeafBlocksAtRange(selection)
+        .toArray()
+        .map(block => block.text);
+
+      const copiedText = getCopiedText(selectedBlocks, startOffset, endOffset);
+      if (copiedText) {
+        event.clipboardData.setData('Text', copiedText);
+      }
+
+      return true;
+    },
+
+    onPaste(event: ClipboardEvent, editor: CoreEditor) {
+      event.preventDefault();
+      const pastedValue = event.clipboardData.getData('Text');
+      const lines = pastedValue.split('\n');
+
+      if (lines.length) {
+        editor.insertText(lines[0]);
+        for (const line of lines.slice(1)) {
+          editor.splitBlock().insertText(line);
+        }
+      }
+
+      return true;
+    },
+  };
+
+  return {
+    ...clipboardPlugin,
+    onCut(event: ClipboardEvent, editor: CoreEditor) {
+      clipboardPlugin.onCopy(event, editor);
+      editor.deleteAtRange(editor.value.selection);
+
+      return true;
+    },
+  };
+}

+ 93 - 0
public/app/features/explore/slate-plugins/indentation.ts

@@ -0,0 +1,93 @@
+import { RangeJSON, Range as SlateRange, Editor as CoreEditor } from 'slate';
+import { Plugin } from '@grafana/slate-react';
+import { isKeyHotkey } from 'is-hotkey';
+
+const isIndentLeftHotkey = isKeyHotkey('mod+[');
+const isShiftTabHotkey = isKeyHotkey('shift+tab');
+const isIndentRightHotkey = isKeyHotkey('mod+]');
+
+const SLATE_TAB = '  ';
+
+const handleTabKey = (event: KeyboardEvent, editor: CoreEditor, next: Function): void => {
+  const {
+    startBlock,
+    endBlock,
+    selection: {
+      start: { offset: startOffset, key: startKey },
+      end: { offset: endOffset, key: endKey },
+    },
+  } = editor.value;
+
+  const first = startBlock.getFirstText();
+
+  const startBlockIsSelected =
+    startOffset === 0 && startKey === first.key && endOffset === first.text.length && endKey === first.key;
+
+  if (startBlockIsSelected || !startBlock.equals(endBlock)) {
+    handleIndent(editor, 'right');
+  } else {
+    editor.insertText(SLATE_TAB);
+  }
+};
+
+const handleIndent = (editor: CoreEditor, indentDirection: 'left' | 'right') => {
+  const curSelection = editor.value.selection;
+  const selectedBlocks = editor.value.document.getLeafBlocksAtRange(curSelection).toArray();
+
+  if (indentDirection === 'left') {
+    for (const block of selectedBlocks) {
+      const blockWhitespace = block.text.length - block.text.trimLeft().length;
+
+      const textKey = block.getFirstText().key;
+
+      const rangeProperties: RangeJSON = {
+        anchor: {
+          key: textKey,
+          offset: blockWhitespace,
+          path: [],
+        },
+        focus: {
+          key: textKey,
+          offset: blockWhitespace,
+          path: [],
+        },
+      };
+
+      editor.deleteBackwardAtRange(SlateRange.create(rangeProperties), Math.min(SLATE_TAB.length, blockWhitespace));
+    }
+  } else {
+    const { startText } = editor.value;
+    const textBeforeCaret = startText.text.slice(0, curSelection.start.offset);
+    const isWhiteSpace = /^\s*$/.test(textBeforeCaret);
+
+    for (const block of selectedBlocks) {
+      editor.insertTextByKey(block.getFirstText().key, 0, SLATE_TAB);
+    }
+
+    if (isWhiteSpace) {
+      editor.moveStartBackward(SLATE_TAB.length);
+    }
+  }
+};
+
+// Clears the rest of the line after the caret
+export default function IndentationPlugin(): Plugin {
+  return {
+    onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) {
+      if (isIndentLeftHotkey(event) || isShiftTabHotkey(event)) {
+        event.preventDefault();
+        handleIndent(editor, 'left');
+      } else if (isIndentRightHotkey(event)) {
+        event.preventDefault();
+        handleIndent(editor, 'right');
+      } else if (event.key === 'Tab') {
+        event.preventDefault();
+        handleTabKey(event, editor, next);
+      } else {
+        return next();
+      }
+
+      return true;
+    },
+  };
+}

+ 12 - 9
public/app/features/explore/slate-plugins/newline.ts

@@ -1,7 +1,7 @@
-// @ts-ignore
-import { Change } from 'slate';
+import { Plugin } from '@grafana/slate-react';
+import { Editor as CoreEditor } from 'slate';
 
-function getIndent(text: any) {
+function getIndent(text: string) {
   let offset = text.length - text.trimLeft().length;
   if (offset) {
     let indent = text[0];
@@ -13,12 +13,13 @@ function getIndent(text: any) {
   return '';
 }
 
-export default function NewlinePlugin() {
+export default function NewlinePlugin(): Plugin {
   return {
-    onKeyDown(event: KeyboardEvent, change: Change) {
-      const { value } = change;
-      if (!value.isCollapsed) {
-        return undefined;
+    onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) {
+      const value = editor.value;
+
+      if (value.selection.isExpanded) {
+        return next();
       }
 
       if (event.key === 'Enter' && event.shiftKey) {
@@ -28,11 +29,13 @@ export default function NewlinePlugin() {
         const currentLineText = startBlock.text;
         const indent = getIndent(currentLineText);
 
-        return change
+        return editor
           .splitBlock()
           .insertText(indent)
           .focus();
       }
+
+      return next();
     },
   };
 }

+ 17 - 0
public/app/features/explore/slate-plugins/runner.test.tsx

@@ -0,0 +1,17 @@
+import Plain from 'slate-plain-serializer';
+import React from 'react';
+import { Editor } from '@grafana/slate-react';
+import { shallow } from 'enzyme';
+import RunnerPlugin from './runner';
+
+describe('runner', () => {
+  const mockHandler = jest.fn();
+  const handler = RunnerPlugin({ handler: mockHandler }).onKeyDown;
+
+  it('should execute query when enter is pressed and there are no suggestions visible', () => {
+    const value = Plain.deserialize('');
+    const editor = shallow<Editor>(<Editor value={value} />);
+    handler({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent, editor.instance() as any, () => {});
+    expect(mockHandler).toBeCalled();
+  });
+});

+ 5 - 2
public/app/features/explore/slate-plugins/runner.ts

@@ -1,6 +1,8 @@
+import { Editor as SlateEditor } from 'slate';
+
 export default function RunnerPlugin({ handler }: any) {
   return {
-    onKeyDown(event: any) {
+    onKeyDown(event: KeyboardEvent, editor: SlateEditor, next: Function) {
       // Handle enter
       if (handler && event.key === 'Enter' && !event.shiftKey) {
         // Submit on Enter
@@ -8,7 +10,8 @@ export default function RunnerPlugin({ handler }: any) {
         handler(event);
         return true;
       }
-      return undefined;
+
+      return next();
     },
   };
 }

+ 72 - 0
public/app/features/explore/slate-plugins/selection_shortcuts.ts

@@ -0,0 +1,72 @@
+import { Plugin } from '@grafana/slate-react';
+import { Editor as CoreEditor } from 'slate';
+
+import { isKeyHotkey } from 'is-hotkey';
+
+const isSelectLeftHotkey = isKeyHotkey('shift+left');
+const isSelectRightHotkey = isKeyHotkey('shift+right');
+const isSelectUpHotkey = isKeyHotkey('shift+up');
+const isSelectDownHotkey = isKeyHotkey('shift+down');
+const isSelectLineHotkey = isKeyHotkey('mod+l');
+
+const handleSelectVertical = (editor: CoreEditor, direction: 'up' | 'down') => {
+  const { focusBlock } = editor.value;
+  const adjacentBlock =
+    direction === 'up'
+      ? editor.value.document.getPreviousBlock(focusBlock.key)
+      : editor.value.document.getNextBlock(focusBlock.key);
+
+  if (!adjacentBlock) {
+    return true;
+  }
+  const adjacentText = adjacentBlock.getFirstText();
+  editor
+    .moveFocusTo(adjacentText.key, Math.min(editor.value.selection.anchor.offset, adjacentText.text.length))
+    .focus();
+  return true;
+};
+
+const handleSelectUp = (editor: CoreEditor) => handleSelectVertical(editor, 'up');
+
+const handleSelectDown = (editor: CoreEditor) => handleSelectVertical(editor, 'down');
+
+// Clears the rest of the line after the caret
+export default function SelectionShortcutsPlugin(): Plugin {
+  return {
+    onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) {
+      if (isSelectLeftHotkey(event)) {
+        event.preventDefault();
+        if (editor.value.selection.focus.offset > 0) {
+          editor.moveFocusBackward(1);
+        }
+      } else if (isSelectRightHotkey(event)) {
+        event.preventDefault();
+        if (editor.value.selection.focus.offset < editor.value.startText.text.length) {
+          editor.moveFocusForward(1);
+        }
+      } else if (isSelectUpHotkey(event)) {
+        event.preventDefault();
+        handleSelectUp(editor);
+      } else if (isSelectDownHotkey(event)) {
+        event.preventDefault();
+        handleSelectDown(editor);
+      } else if (isSelectLineHotkey(event)) {
+        event.preventDefault();
+        const { focusBlock, document } = editor.value;
+
+        editor.moveAnchorToStartOfBlock();
+
+        const nextBlock = document.getNextBlock(focusBlock.key);
+        if (nextBlock) {
+          editor.moveFocusToStartOfNextBlock();
+        } else {
+          editor.moveFocusToEndOfText();
+        }
+      } else {
+        return next();
+      }
+
+      return true;
+    },
+  };
+}

+ 313 - 0
public/app/features/explore/slate-plugins/suggestions.tsx

@@ -0,0 +1,313 @@
+import React from 'react';
+import debounce from 'lodash/debounce';
+import sortBy from 'lodash/sortBy';
+
+import { Editor as CoreEditor } from 'slate';
+import { Plugin as SlatePlugin } from '@grafana/slate-react';
+import { TypeaheadOutput, CompletionItem, CompletionItemGroup } from 'app/types';
+
+import { QueryField, TypeaheadInput } from '../QueryField';
+import TOKEN_MARK from '@grafana/ui/src/slate-plugins/slate-prism/TOKEN_MARK';
+import { TypeaheadWithTheme, Typeahead } from '../Typeahead';
+
+import { makeFragment } from '@grafana/ui';
+
+export const TYPEAHEAD_DEBOUNCE = 100;
+
+export interface SuggestionsState {
+  groupedItems: CompletionItemGroup[];
+  typeaheadPrefix: string;
+  typeaheadContext: string;
+  typeaheadText: string;
+}
+
+let state: SuggestionsState = {
+  groupedItems: [],
+  typeaheadPrefix: '',
+  typeaheadContext: '',
+  typeaheadText: '',
+};
+
+export default function SuggestionsPlugin({
+  onTypeahead,
+  cleanText,
+  onWillApplySuggestion,
+  syntax,
+  portalOrigin,
+  component,
+}: {
+  onTypeahead: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>;
+  cleanText?: (text: string) => string;
+  onWillApplySuggestion?: (suggestion: string, state: SuggestionsState) => string;
+  syntax?: string;
+  portalOrigin: string;
+  component: QueryField; // Need to attach typeaheadRef here
+}): SlatePlugin {
+  return {
+    onBlur: (event, editor, next) => {
+      state = {
+        ...state,
+        groupedItems: [],
+      };
+
+      return next();
+    },
+
+    onClick: (event, editor, next) => {
+      state = {
+        ...state,
+        groupedItems: [],
+      };
+
+      return next();
+    },
+
+    onKeyDown: (event: KeyboardEvent, editor, next) => {
+      const currentSuggestions = state.groupedItems;
+
+      const hasSuggestions = currentSuggestions.length;
+
+      switch (event.key) {
+        case 'Escape': {
+          if (hasSuggestions) {
+            event.preventDefault();
+
+            state = {
+              ...state,
+              groupedItems: [],
+            };
+
+            // Bogus edit to re-render editor
+            return editor.insertText('');
+          }
+
+          break;
+        }
+
+        case 'ArrowDown':
+        case 'ArrowUp':
+          if (hasSuggestions) {
+            event.preventDefault();
+            component.typeaheadRef.moveMenuIndex(event.key === 'ArrowDown' ? 1 : -1);
+            return;
+          }
+
+          break;
+
+        case 'Enter':
+        case 'Tab': {
+          if (hasSuggestions) {
+            event.preventDefault();
+
+            component.typeaheadRef.insertSuggestion();
+            return handleTypeahead(event, editor, next, onTypeahead, cleanText);
+          }
+
+          break;
+        }
+
+        default: {
+          handleTypeahead(event, editor, next, onTypeahead, cleanText);
+          break;
+        }
+      }
+
+      return next();
+    },
+
+    commands: {
+      selectSuggestion: (editor: CoreEditor, suggestion: CompletionItem): CoreEditor => {
+        const suggestions = state.groupedItems;
+        if (!suggestions || !suggestions.length) {
+          return editor;
+        }
+
+        // @ts-ignore
+        return editor.applyTypeahead(suggestion);
+      },
+
+      applyTypeahead: (editor: CoreEditor, suggestion: CompletionItem): CoreEditor => {
+        let suggestionText = suggestion.insertText || suggestion.label;
+
+        const preserveSuffix = suggestion.kind === 'function';
+        const move = suggestion.move || 0;
+
+        const { typeaheadPrefix, typeaheadText, typeaheadContext } = state;
+
+        if (onWillApplySuggestion) {
+          suggestionText = onWillApplySuggestion(suggestionText, {
+            groupedItems: state.groupedItems,
+            typeaheadContext,
+            typeaheadPrefix,
+            typeaheadText,
+          });
+        }
+
+        // Remove the current, incomplete text and replace it with the selected suggestion
+        const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
+        const text = cleanText ? cleanText(typeaheadText) : typeaheadText;
+        const suffixLength = text.length - typeaheadPrefix.length;
+        const offset = typeaheadText.indexOf(typeaheadPrefix);
+        const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
+        const forward = midWord && !preserveSuffix ? suffixLength + offset : 0;
+
+        // If new-lines, apply suggestion as block
+        if (suggestionText.match(/\n/)) {
+          const fragment = makeFragment(suggestionText);
+          return editor
+            .deleteBackward(backward)
+            .deleteForward(forward)
+            .insertFragment(fragment)
+            .focus();
+        }
+
+        state = {
+          ...state,
+          groupedItems: [],
+        };
+
+        return editor
+          .deleteBackward(backward)
+          .deleteForward(forward)
+          .insertText(suggestionText)
+          .moveForward(move)
+          .focus();
+      },
+    },
+
+    renderEditor: (props, editor, next) => {
+      if (editor.value.selection.isExpanded) {
+        return next();
+      }
+
+      const children = next();
+
+      return (
+        <>
+          {children}
+          <TypeaheadWithTheme
+            menuRef={(el: Typeahead) => (component.typeaheadRef = el)}
+            origin={portalOrigin}
+            prefix={state.typeaheadPrefix}
+            isOpen={!!state.groupedItems.length}
+            groupedItems={state.groupedItems}
+            //@ts-ignore
+            onSelectSuggestion={editor.selectSuggestion}
+          />
+        </>
+      );
+    },
+  };
+}
+
+const handleTypeahead = debounce(
+  async (
+    event: Event,
+    editor: CoreEditor,
+    next: () => {},
+    onTypeahead?: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>,
+    cleanText?: (text: string) => string
+  ) => {
+    if (!onTypeahead) {
+      return next();
+    }
+
+    const { value } = editor;
+    const { selection } = value;
+
+    // Get decorations associated with the current line
+    const parentBlock = value.document.getClosestBlock(value.focusBlock.key);
+    const myOffset = value.selection.start.offset - 1;
+    const decorations = parentBlock.getDecorations(editor as any);
+
+    const filteredDecorations = decorations
+      .filter(
+        decoration =>
+          decoration.start.offset <= myOffset && decoration.end.offset > myOffset && decoration.type === TOKEN_MARK
+      )
+      .toArray();
+
+    const labelKeyDec = decorations
+      .filter(
+        decoration =>
+          decoration.end.offset === myOffset &&
+          decoration.type === TOKEN_MARK &&
+          decoration.data.get('className').includes('label-key')
+      )
+      .first();
+
+    const labelKey = labelKeyDec && value.focusText.text.slice(labelKeyDec.start.offset, labelKeyDec.end.offset);
+
+    const wrapperClasses = filteredDecorations
+      .map(decoration => decoration.data.get('className'))
+      .join(' ')
+      .split(' ')
+      .filter(className => className.length);
+
+    let text = value.focusText.text;
+    let prefix = text.slice(0, selection.focus.offset);
+
+    if (filteredDecorations.length) {
+      text = value.focusText.text.slice(filteredDecorations[0].start.offset, filteredDecorations[0].end.offset);
+      prefix = value.focusText.text.slice(filteredDecorations[0].start.offset, selection.focus.offset);
+    }
+
+    // Label values could have valid characters erased if `cleanText()` is
+    // blindly applied, which would undesirably interfere with suggestions
+    const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/);
+    if (labelValueMatch) {
+      prefix = labelValueMatch[1];
+    } else if (cleanText) {
+      prefix = cleanText(prefix);
+    }
+
+    const { suggestions, context } = await onTypeahead({
+      prefix,
+      text,
+      value,
+      wrapperClasses,
+      labelKey,
+    });
+
+    const filteredSuggestions = suggestions
+      .map(group => {
+        if (!group.items) {
+          return group;
+        }
+
+        if (prefix) {
+          // Filter groups based on prefix
+          if (!group.skipFilter) {
+            group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length);
+            if (group.prefixMatch) {
+              group.items = group.items.filter(c => (c.filterText || c.label).startsWith(prefix));
+            } else {
+              group.items = group.items.filter(c => (c.filterText || c.label).includes(prefix));
+            }
+          }
+
+          // Filter out the already typed value (prefix) unless it inserts custom text
+          group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix);
+        }
+
+        if (!group.skipSort) {
+          group.items = sortBy(group.items, (item: CompletionItem) => item.sortText || item.label);
+        }
+
+        return group;
+      })
+      .filter(group => group.items && group.items.length); // Filter out empty groups
+
+    state = {
+      ...state,
+      groupedItems: filteredSuggestions,
+      typeaheadPrefix: prefix,
+      typeaheadContext: context,
+      typeaheadText: text,
+    };
+
+    // Bogus edit to force re-render
+    return editor.insertText('');
+  },
+  TYPEAHEAD_DEBOUNCE
+);

+ 3 - 5
public/app/features/explore/utils/typeahead.ts

@@ -1,14 +1,13 @@
 import { GrafanaTheme } from '@grafana/ui';
 import { default as calculateSize } from 'calculate-size';
 
-import { CompletionItemGroup, CompletionItem } from 'app/types';
-import { GROUP_TITLE_KIND } from '../TypeaheadItem';
+import { CompletionItemGroup, CompletionItem, CompletionItemKind } from 'app/types';
 
 export const flattenGroupItems = (groupedItems: CompletionItemGroup[]): CompletionItem[] => {
   return groupedItems.reduce((all, current) => {
     const titleItem: CompletionItem = {
       label: current.label,
-      kind: GROUP_TITLE_KIND,
+      kind: CompletionItemKind.GroupTitle,
     };
     return all.concat(titleItem, current.items);
   }, []);
@@ -56,8 +55,7 @@ export const calculateListWidth = (longestLabelWidth: number, theme: GrafanaThem
 export const calculateListHeight = (itemHeight: number, allItems: CompletionItem[]) => {
   const numberOfItemsToShow = Math.min(allItems.length, 10);
   const minHeight = 100;
-  const itemsInView = allItems.slice(0, numberOfItemsToShow);
-  const totalHeight = itemsInView.length * itemHeight;
+  const totalHeight = numberOfItemsToShow * itemHeight;
   const listHeight = Math.max(totalHeight, minHeight);
 
   return listHeight;

+ 2 - 2
public/app/features/plugins/plugin_loader.ts

@@ -10,7 +10,7 @@ import jquery from 'jquery';
 import prismjs from 'prismjs';
 import slate from 'slate';
 // @ts-ignore
-import slateReact from 'slate-react';
+import slateReact from '@grafana/slate-react';
 // @ts-ignore
 import slatePlain from 'slate-plain-serializer';
 import react from 'react';
@@ -91,7 +91,7 @@ exposeToPlugin('rxjs', {
 // Experimental modules
 exposeToPlugin('prismjs', prismjs);
 exposeToPlugin('slate', slate);
-exposeToPlugin('slate-react', slateReact);
+exposeToPlugin('@grafana/slate-react', slateReact);
 exposeToPlugin('slate-plain-serializer', slatePlain);
 exposeToPlugin('react', react);
 exposeToPlugin('react-dom', reactDom);

+ 3 - 5
public/app/plugins/datasource/elasticsearch/components/ElasticsearchQueryField.tsx

@@ -1,9 +1,7 @@
 import _ from 'lodash';
 import React from 'react';
-// @ts-ignore
-import PluginPrism from 'slate-prism';
-// @ts-ignore
-import Prism from 'prismjs';
+
+import { SlatePrism } from '@grafana/ui';
 
 // dom also includes Element polyfills
 import QueryField from 'app/features/explore/QueryField';
@@ -24,7 +22,7 @@ class ElasticsearchQueryField extends React.PureComponent<Props, State> {
     super(props, context);
 
     this.plugins = [
-      PluginPrism({
+      SlatePrism({
         onlyIn: (node: any) => node.type === 'code_block',
         getSyntax: (node: any) => 'lucene',
       }),

+ 9 - 8
public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx

@@ -1,12 +1,13 @@
 import _ from 'lodash';
-// @ts-ignore
 import Plain from 'slate-plain-serializer';
 
 import QueryField from './query_field';
 import debounce from 'lodash/debounce';
 import { DOMUtil } from '@grafana/ui';
+import { Editor as SlateEditor } from 'slate';
 
 import { KEYWORDS, functionTokens, operatorTokens, grafanaMacros } from './kusto/kusto';
+import { CompletionItem } from 'app/types';
 // import '../sass/editor.base.scss';
 
 const TYPEAHEAD_DELAY = 100;
@@ -63,7 +64,7 @@ export default class KustoQueryField extends QueryField {
     this.fetchSchema();
   }
 
-  onTypeahead = (force?: boolean) => {
+  onTypeahead = (force = false) => {
     const selection = window.getSelection();
     if (selection.anchorNode) {
       const wrapperNode = selection.anchorNode.parentElement;
@@ -196,15 +197,15 @@ export default class KustoQueryField extends QueryField {
     }
   };
 
-  applyTypeahead(change: any, suggestion: { text: any; type: string; deleteBackwards: any }) {
+  applyTypeahead = (editor: SlateEditor, suggestion: CompletionItem): SlateEditor => {
     const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state;
-    let suggestionText = suggestion.text || suggestion;
+    let suggestionText = suggestion.label;
     const move = 0;
 
     // Modify suggestion based on context
 
     const nextChar = DOMUtil.getNextCharacter();
-    if (suggestion.type === 'function') {
+    if (suggestion.kind === 'function') {
       if (!nextChar || nextChar !== '(') {
         suggestionText += '(';
       }
@@ -228,13 +229,13 @@ export default class KustoQueryField extends QueryField {
     const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
     const forward = midWord ? suffixLength + offset : 0;
 
-    return change
+    return editor
       .deleteBackward(backward)
       .deleteForward(forward)
       .insertText(suggestionText)
-      .move(move)
+      .moveForward(move)
       .focus();
-  }
+  };
 
   // private _getFieldsSuggestions(): SuggestionGroup[] {
   //   return [

+ 23 - 36
public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/query_field.tsx

@@ -7,14 +7,13 @@ import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
 import Typeahead from './typeahead';
 import { getKeybindingSrv, KeybindingSrv } from 'app/core/services/keybindingSrv';
 
-import { Block, Document, Text, Value } from 'slate';
-// @ts-ignore
-import { Editor } from 'slate-react';
-// @ts-ignore
+import { Block, Document, Text, Value, Editor as CoreEditor } from 'slate';
+import { Editor } from '@grafana/slate-react';
 import Plain from 'slate-plain-serializer';
 import ReactDOM from 'react-dom';
 import React from 'react';
 import _ from 'lodash';
+import { CompletionItem } from 'app/types';
 
 function flattenSuggestions(s: any) {
   return s ? s.reduce((acc: any, g: any) => acc.concat(g.items), []) : [];
@@ -98,7 +97,7 @@ class QueryField extends React.Component<any, any> {
     this.updateMenu();
   }
 
-  onChange = ({ value }: any) => {
+  onChange = ({ value }: { value: Value }) => {
     const changed = value.document !== this.state.value.document;
     this.setState({ value }, () => {
       if (changed) {
@@ -124,14 +123,15 @@ class QueryField extends React.Component<any, any> {
     }
   };
 
-  onKeyDown = (event: any, change: any) => {
+  onKeyDown = (event: Event, editor: CoreEditor, next: Function) => {
     const { typeaheadIndex, suggestions } = this.state;
+    const keyboardEvent = event as KeyboardEvent;
 
-    switch (event.key) {
+    switch (keyboardEvent.key) {
       case 'Escape': {
         if (this.menuEl) {
-          event.preventDefault();
-          event.stopPropagation();
+          keyboardEvent.preventDefault();
+          keyboardEvent.stopPropagation();
           this.resetTypeahead();
           return true;
         }
@@ -139,8 +139,8 @@ class QueryField extends React.Component<any, any> {
       }
 
       case ' ': {
-        if (event.ctrlKey) {
-          event.preventDefault();
+        if (keyboardEvent.ctrlKey) {
+          keyboardEvent.preventDefault();
           this.onTypeahead(true);
           return true;
         }
@@ -151,18 +151,12 @@ class QueryField extends React.Component<any, any> {
       case 'Enter': {
         if (this.menuEl) {
           // Dont blur input
-          event.preventDefault();
+          keyboardEvent.preventDefault();
           if (!suggestions || suggestions.length === 0) {
-            return undefined;
+            return next();
           }
 
-          // Get the currently selected suggestion
-          const flattenedSuggestions = flattenSuggestions(suggestions);
-          const selected = Math.abs(typeaheadIndex);
-          const selectedIndex = selected % flattenedSuggestions.length || 0;
-          const suggestion = flattenedSuggestions[selectedIndex];
-
-          this.applyTypeahead(change, suggestion);
+          this.applyTypeahead();
           return true;
         }
         break;
@@ -171,7 +165,7 @@ class QueryField extends React.Component<any, any> {
       case 'ArrowDown': {
         if (this.menuEl) {
           // Select next suggestion
-          event.preventDefault();
+          keyboardEvent.preventDefault();
           this.setState({ typeaheadIndex: typeaheadIndex + 1 });
         }
         break;
@@ -180,7 +174,7 @@ class QueryField extends React.Component<any, any> {
       case 'ArrowUp': {
         if (this.menuEl) {
           // Select previous suggestion
-          event.preventDefault();
+          keyboardEvent.preventDefault();
           this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) });
         }
         break;
@@ -191,16 +185,16 @@ class QueryField extends React.Component<any, any> {
         break;
       }
     }
-    return undefined;
+    return next();
   };
 
-  onTypeahead = (change?: boolean, item?: any) => {
-    return change || this.state.value.change();
+  onTypeahead = (change = false, item?: any): boolean | void => {
+    return change;
   };
 
-  applyTypeahead(change?: boolean, suggestion?: any): { value: object } {
-    return { value: {} };
-  }
+  applyTypeahead = (editor?: CoreEditor, suggestion?: CompletionItem): { value: Value } => {
+    return { value: new Value() };
+  };
 
   resetTypeahead = () => {
     this.setState({
@@ -245,15 +239,8 @@ class QueryField extends React.Component<any, any> {
       return;
     }
 
-    // Get the currently selected suggestion
-    const flattenedSuggestions = flattenSuggestions(suggestions);
-    const suggestion: any = _.find(
-      flattenedSuggestions,
-      suggestion => suggestion.display === item || suggestion.text === item
-    );
-
     // Manually triggering change
-    const change = this.applyTypeahead(this.state.value.change(), suggestion);
+    const change = this.applyTypeahead();
     this.onChange(change);
   };
 

+ 2 - 1
public/app/plugins/datasource/loki/components/LokiQueryField.tsx

@@ -1,6 +1,7 @@
 import React, { FunctionComponent } from 'react';
 import { LokiQueryFieldForm, LokiQueryFieldFormProps } from './LokiQueryFieldForm';
 import { useLokiSyntax } from './useLokiSyntax';
+import LokiLanguageProvider from '../language_provider';
 
 export const LokiQueryField: FunctionComponent<LokiQueryFieldFormProps> = ({
   datasource,
@@ -8,7 +9,7 @@ export const LokiQueryField: FunctionComponent<LokiQueryFieldFormProps> = ({
   ...otherProps
 }) => {
   const { isSyntaxReady, setActiveOption, refreshLabels, ...syntaxProps } = useLokiSyntax(
-    datasource.languageProvider,
+    datasource.languageProvider as LokiLanguageProvider,
     datasourceStatus,
     otherProps.absoluteRange
   );

+ 26 - 23
public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx

@@ -2,18 +2,24 @@
 import React from 'react';
 // @ts-ignore
 import Cascader from 'rc-cascader';
-// @ts-ignore
-import PluginPrism from 'slate-prism';
+
+import { SlatePrism } from '@grafana/ui';
+
 // Components
-import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField';
+import QueryField, { TypeaheadInput } from 'app/features/explore/QueryField';
 // Utils & Services
 // dom also includes Element polyfills
 import BracesPlugin from 'app/features/explore/slate-plugins/braces';
+import { Plugin, Node } from 'slate';
+
 // Types
 import { LokiQuery } from '../types';
-import { TypeaheadOutput, HistoryItem } from 'app/types/explore';
+import { TypeaheadOutput } from 'app/types/explore';
 import { DataSourceApi, ExploreQueryFieldProps, DataSourceStatus, DOMUtil } from '@grafana/ui';
 import { AbsoluteTimeRange } from '@grafana/data';
+import { Grammar } from 'prismjs';
+import LokiLanguageProvider, { LokiHistoryItem } from '../language_provider';
+import { SuggestionsState } from 'app/features/explore/slate-plugins/suggestions';
 
 function getChooserText(hasSyntax: boolean, hasLogLabels: boolean, datasourceStatus: DataSourceStatus) {
   if (datasourceStatus === DataSourceStatus.Disconnected) {
@@ -28,7 +34,7 @@ function getChooserText(hasSyntax: boolean, hasLogLabels: boolean, datasourceSta
   return 'Log labels';
 }
 
-function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: QueryFieldState): string {
+function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: SuggestionsState): string {
   // Modify suggestion based on context
   switch (typeaheadContext) {
     case 'context-labels': {
@@ -63,17 +69,17 @@ export interface CascaderOption {
 }
 
 export interface LokiQueryFieldFormProps extends ExploreQueryFieldProps<DataSourceApi<LokiQuery>, LokiQuery> {
-  history: HistoryItem[];
-  syntax: any;
+  history: LokiHistoryItem[];
+  syntax: Grammar;
   logLabelOptions: any[];
-  syntaxLoaded: any;
+  syntaxLoaded: boolean;
   absoluteRange: AbsoluteTimeRange;
   onLoadOptions: (selectedOptions: CascaderOption[]) => void;
   onLabelsRefresh?: () => void;
 }
 
 export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormProps> {
-  plugins: any[];
+  plugins: Plugin[];
   modifiedSearch: string;
   modifiedQuery: string;
 
@@ -82,9 +88,9 @@ export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormPr
 
     this.plugins = [
       BracesPlugin(),
-      PluginPrism({
-        onlyIn: (node: any) => node.type === 'code_block',
-        getSyntax: (node: any) => 'promql',
+      SlatePrism({
+        onlyIn: (node: Node) => node.object === 'block' && node.type === 'code_block',
+        getSyntax: (node: Node) => 'promql',
       }),
     ];
   }
@@ -115,27 +121,23 @@ export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormPr
     }
   };
 
-  onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
+  onTypeahead = async (typeahead: TypeaheadInput): Promise<TypeaheadOutput> => {
     const { datasource } = this.props;
+
     if (!datasource.languageProvider) {
       return { suggestions: [] };
     }
 
+    const lokiLanguageProvider = datasource.languageProvider as LokiLanguageProvider;
     const { history, absoluteRange } = this.props;
-    const { prefix, text, value, wrapperNode } = typeahead;
-
-    // Get DOM-dependent context
-    const wrapperClasses = Array.from(wrapperNode.classList);
-    const labelKeyNode = DOMUtil.getPreviousCousin(wrapperNode, '.attr-name');
-    const labelKey = labelKeyNode && labelKeyNode.textContent;
-    const nextChar = DOMUtil.getNextCharacter();
+    const { prefix, text, value, wrapperClasses, labelKey } = typeahead;
 
-    const result = datasource.languageProvider.provideCompletionItems(
+    const result = await lokiLanguageProvider.provideCompletionItems(
       { text, value, prefix, wrapperClasses, labelKey },
       { history, absoluteRange }
     );
 
-    console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
+    //console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
 
     return result;
   };
@@ -151,7 +153,8 @@ export class LokiQueryFieldForm extends React.PureComponent<LokiQueryFieldFormPr
       datasource,
       datasourceStatus,
     } = this.props;
-    const cleanText = datasource.languageProvider ? datasource.languageProvider.cleanText : undefined;
+    const lokiLanguageProvider = datasource.languageProvider as LokiLanguageProvider;
+    const cleanText = datasource.languageProvider ? lokiLanguageProvider.cleanText : undefined;
     const hasLogLabels = logLabelOptions && logLabelOptions.length > 0;
     const chooserText = getChooserText(syntaxLoaded, hasLogLabels, datasourceStatus);
     const buttonDisabled = !syntaxLoaded || datasourceStatus === DataSourceStatus.Disconnected;

+ 1 - 0
public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts

@@ -3,6 +3,7 @@ import { DataSourceStatus } from '@grafana/ui/src/types/datasource';
 import { AbsoluteTimeRange } from '@grafana/data';
 
 import LanguageProvider from 'app/plugins/datasource/loki/language_provider';
+
 import { useLokiSyntax } from './useLokiSyntax';
 import { CascaderOption } from 'app/plugins/datasource/loki/components/LokiQueryFieldForm';
 import { makeMockLokiDatasource } from '../mocks';

+ 0 - 1
public/app/plugins/datasource/loki/components/useLokiSyntax.ts

@@ -1,5 +1,4 @@
 import { useState, useEffect } from 'react';
-// @ts-ignore
 import Prism from 'prismjs';
 import { DataSourceStatus } from '@grafana/ui/src/types/datasource';
 import { AbsoluteTimeRange } from '@grafana/data';

+ 32 - 25
public/app/plugins/datasource/loki/language_provider.test.ts

@@ -1,13 +1,14 @@
-// @ts-ignore
 import Plain from 'slate-plain-serializer';
+import { Editor as SlateEditor } from 'slate';
 
 import LanguageProvider, { LABEL_REFRESH_INTERVAL, LokiHistoryItem, rangeToParams } from './language_provider';
 import { AbsoluteTimeRange } from '@grafana/data';
 import { advanceTo, clear, advanceBy } from 'jest-date-mock';
 import { beforeEach } from 'test/lib/common';
-import { DataSourceApi } from '@grafana/ui';
+
 import { TypeaheadInput } from '../../../types';
 import { makeMockLokiDatasource } from './mocks';
+import LokiDatasource from './datasource';
 
 describe('Language completion provider', () => {
   const datasource = makeMockLokiDatasource({});
@@ -18,16 +19,16 @@ describe('Language completion provider', () => {
   };
 
   describe('empty query suggestions', () => {
-    it('returns no suggestions on empty context', () => {
+    it('returns no suggestions on empty context', async () => {
       const instance = new LanguageProvider(datasource);
       const value = Plain.deserialize('');
-      const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] });
+      const result = await instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] });
       expect(result.context).toBeUndefined();
-      expect(result.refresher).toBeUndefined();
+
       expect(result.suggestions.length).toEqual(0);
     });
 
-    it('returns default suggestions with history on empty context when history was provided', () => {
+    it('returns default suggestions with history on empty context when history was provided', async () => {
       const instance = new LanguageProvider(datasource);
       const value = Plain.deserialize('');
       const history: LokiHistoryItem[] = [
@@ -36,12 +37,12 @@ describe('Language completion provider', () => {
           ts: 1,
         },
       ];
-      const result = instance.provideCompletionItems(
+      const result = await instance.provideCompletionItems(
         { text: '', prefix: '', value, wrapperClasses: [] },
         { history, absoluteRange: rangeMock }
       );
       expect(result.context).toBeUndefined();
-      expect(result.refresher).toBeUndefined();
+
       expect(result.suggestions).toMatchObject([
         {
           label: 'History',
@@ -54,7 +55,7 @@ describe('Language completion provider', () => {
       ]);
     });
 
-    it('returns no suggestions within regexp', () => {
+    it('returns no suggestions within regexp', async () => {
       const instance = new LanguageProvider(datasource);
       const input = createTypeaheadInput('{} ()', '', undefined, 4, []);
       const history: LokiHistoryItem[] = [
@@ -63,18 +64,28 @@ describe('Language completion provider', () => {
           ts: 1,
         },
       ];
-      const result = instance.provideCompletionItems(input, { history });
+      const result = await instance.provideCompletionItems(input, { history });
       expect(result.context).toBeUndefined();
-      expect(result.refresher).toBeUndefined();
+
       expect(result.suggestions.length).toEqual(0);
     });
   });
 
   describe('label suggestions', () => {
-    it('returns default label suggestions on label context', () => {
+    it('returns default label suggestions on label context', async () => {
       const instance = new LanguageProvider(datasource);
-      const input = createTypeaheadInput('{}', '');
-      const result = instance.provideCompletionItems(input, { absoluteRange: rangeMock });
+      const value = Plain.deserialize('{}');
+      const ed = new SlateEditor({ value });
+      const valueWithSelection = ed.moveForward(1).value;
+      const result = await instance.provideCompletionItems(
+        {
+          text: '',
+          prefix: '',
+          wrapperClasses: ['context-labels'],
+          value: valueWithSelection,
+        },
+        { absoluteRange: rangeMock }
+      );
       expect(result.context).toBe('context-labels');
       expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'namespace' }], label: 'Labels' }]);
     });
@@ -83,7 +94,7 @@ describe('Language completion provider', () => {
       const datasource = makeMockLokiDatasource({ label1: [], label2: [] });
       const provider = await getLanguageProvider(datasource);
       const input = createTypeaheadInput('{}', '');
-      const result = provider.provideCompletionItems(input, { absoluteRange: rangeMock });
+      const result = await provider.provideCompletionItems(input, { absoluteRange: rangeMock });
       expect(result.context).toBe('context-labels');
       expect(result.suggestions).toEqual([{ items: [{ label: 'label1' }, { label: 'label2' }], label: 'Labels' }]);
     });
@@ -92,11 +103,9 @@ describe('Language completion provider', () => {
       const datasource = makeMockLokiDatasource({ label1: ['label1_val1', 'label1_val2'], label2: [] });
       const provider = await getLanguageProvider(datasource);
       const input = createTypeaheadInput('{label1=}', '=', 'label1');
-      let result = provider.provideCompletionItems(input, { absoluteRange: rangeMock });
-      // The values for label are loaded adhoc and there is a promise returned that we have to wait for
-      expect(result.refresher).toBeDefined();
-      await result.refresher;
-      result = provider.provideCompletionItems(input, { absoluteRange: rangeMock });
+      let result = await provider.provideCompletionItems(input, { absoluteRange: rangeMock });
+
+      result = await provider.provideCompletionItems(input, { absoluteRange: rangeMock });
       expect(result.context).toBe('context-label-values');
       expect(result.suggestions).toEqual([
         { items: [{ label: 'label1_val1' }, { label: 'label1_val2' }], label: 'Label values for "label1"' },
@@ -201,7 +210,7 @@ describe('Labels refresh', () => {
   });
 });
 
-async function getLanguageProvider(datasource: DataSourceApi) {
+async function getLanguageProvider(datasource: LokiDatasource) {
   const instance = new LanguageProvider(datasource);
   instance.initialRange = {
     from: Date.now() - 10000,
@@ -224,10 +233,8 @@ function createTypeaheadInput(
   wrapperClasses?: string[]
 ): TypeaheadInput {
   const deserialized = Plain.deserialize(value);
-  const range = deserialized.selection.merge({
-    anchorOffset: anchorOffset || 1,
-  });
-  const valueWithSelection = deserialized.change().select(range).value;
+  const range = deserialized.selection.setAnchor(deserialized.selection.anchor.setOffset(anchorOffset || 1));
+  const valueWithSelection = deserialized.setSelection(range);
   return {
     text,
     prefix: '',

+ 25 - 29
public/app/plugins/datasource/loki/language_provider.ts

@@ -6,18 +6,12 @@ import { parseSelector, labelRegexp, selectorRegexp } from 'app/plugins/datasour
 import syntax from './syntax';
 
 // Types
-import {
-  CompletionItem,
-  CompletionItemGroup,
-  LanguageProvider,
-  TypeaheadInput,
-  TypeaheadOutput,
-  HistoryItem,
-} from 'app/types/explore';
+import { CompletionItem, LanguageProvider, TypeaheadInput, TypeaheadOutput, HistoryItem } from 'app/types/explore';
 import { LokiQuery } from './types';
 import { dateTime, AbsoluteTimeRange } from '@grafana/data';
 import { PromQuery } from '../prometheus/types';
-import { DataSourceApi } from '@grafana/ui';
+
+import LokiDatasource from './datasource';
 
 const DEFAULT_KEYS = ['job', 'namespace'];
 const EMPTY_SELECTOR = '{}';
@@ -59,8 +53,9 @@ export default class LokiLanguageProvider extends LanguageProvider {
   logLabelFetchTs?: number;
   started: boolean;
   initialRange: AbsoluteTimeRange;
+  datasource: LokiDatasource;
 
-  constructor(datasource: DataSourceApi, initialValues?: any) {
+  constructor(datasource: LokiDatasource, initialValues?: any) {
     super();
 
     this.datasource = datasource;
@@ -69,6 +64,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
 
     Object.assign(this, initialValues);
   }
+
   // Strip syntax chars
   cleanText = (s: string) => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
 
@@ -111,14 +107,14 @@ export default class LokiLanguageProvider extends LanguageProvider {
    * @param context.absoluteRange Required in case we are doing getLabelCompletionItems
    * @param context.history Optional used only in getEmptyCompletionItems
    */
-  provideCompletionItems(input: TypeaheadInput, context?: TypeaheadContext): TypeaheadOutput {
+  async provideCompletionItems(input: TypeaheadInput, context?: TypeaheadContext): Promise<TypeaheadOutput> {
     const { wrapperClasses, value } = input;
     // Local text properties
     const empty = value.document.text.length === 0;
     // Determine candidates by CSS context
     if (_.includes(wrapperClasses, 'context-labels')) {
       // Suggestions for {|} and {foo=|}
-      return this.getLabelCompletionItems(input, context);
+      return await this.getLabelCompletionItems(input, context);
     } else if (empty) {
       return this.getEmptyCompletionItems(context || {});
     }
@@ -130,7 +126,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
 
   getEmptyCompletionItems(context: any): TypeaheadOutput {
     const { history } = context;
-    const suggestions: CompletionItemGroup[] = [];
+    const suggestions = [];
 
     if (history && history.length > 0) {
       const historyItems = _.chain(history)
@@ -153,15 +149,14 @@ export default class LokiLanguageProvider extends LanguageProvider {
     return { suggestions };
   }
 
-  getLabelCompletionItems(
+  async getLabelCompletionItems(
     { text, wrapperClasses, labelKey, value }: TypeaheadInput,
     { absoluteRange }: any
-  ): TypeaheadOutput {
+  ): Promise<TypeaheadOutput> {
     let context: string;
-    let refresher: Promise<any> = null;
-    const suggestions: CompletionItemGroup[] = [];
+    const suggestions = [];
     const line = value.anchorBlock.getText();
-    const cursorOffset: number = value.anchorOffset;
+    const cursorOffset: number = value.selection.anchor.offset;
 
     // Use EMPTY_SELECTOR until series API is implemented for facetting
     const selector = EMPTY_SELECTOR;
@@ -171,19 +166,20 @@ export default class LokiLanguageProvider extends LanguageProvider {
     } catch {}
     const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
 
-    if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
+    if ((text && text.match(/^!?=~?/)) || wrapperClasses.includes('attr-value')) {
       // Label values
       if (labelKey && this.labelValues[selector]) {
-        const labelValues = this.labelValues[selector][labelKey];
-        if (labelValues) {
-          context = 'context-label-values';
-          suggestions.push({
-            label: `Label values for "${labelKey}"`,
-            items: labelValues.map(wrapLabel),
-          });
-        } else {
-          refresher = this.fetchLabelValues(labelKey, absoluteRange);
+        let labelValues = this.labelValues[selector][labelKey];
+        if (!labelValues) {
+          await this.fetchLabelValues(labelKey, absoluteRange);
+          labelValues = this.labelValues[selector][labelKey];
         }
+
+        context = 'context-label-values';
+        suggestions.push({
+          label: `Label values for "${labelKey}"`,
+          items: labelValues.map(wrapLabel),
+        });
       }
     } else {
       // Label keys
@@ -197,7 +193,7 @@ export default class LokiLanguageProvider extends LanguageProvider {
       }
     }
 
-    return { context, refresher, suggestions };
+    return { context, suggestions };
   }
 
   async importQueries(queries: LokiQuery[], datasourceType: string): Promise<LokiQuery[]> {

+ 2 - 2
public/app/plugins/datasource/loki/mocks.ts

@@ -1,6 +1,6 @@
-import { DataSourceApi } from '@grafana/ui';
+import LokiDatasource from './datasource';
 
-export function makeMockLokiDatasource(labelsAndValues: { [label: string]: string[] }): DataSourceApi {
+export function makeMockLokiDatasource(labelsAndValues: { [label: string]: string[] }): LokiDatasource {
   const labels = Object.keys(labelsAndValues);
   return {
     metadataRequest: (url: string) => {

+ 3 - 1
public/app/plugins/datasource/loki/syntax.ts

@@ -1,6 +1,8 @@
+import { Grammar } from 'prismjs';
+
 /* tslint:disable max-line-length */
 
-const tokenizer = {
+const tokenizer: Grammar = {
   comment: {
     pattern: /(^|[^\n])#.*/,
     lookbehind: true,

+ 15 - 19
public/app/plugins/datasource/prometheus/components/PromQueryField.tsx

@@ -2,20 +2,22 @@ import _ from 'lodash';
 import React from 'react';
 // @ts-ignore
 import Cascader from 'rc-cascader';
-// @ts-ignore
-import PluginPrism from 'slate-prism';
-// @ts-ignore
+
+import { SlatePrism } from '@grafana/ui';
+
 import Prism from 'prismjs';
 
 import { TypeaheadOutput, HistoryItem } from 'app/types/explore';
 // dom also includes Element polyfills
 import BracesPlugin from 'app/features/explore/slate-plugins/braces';
-import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explore/QueryField';
+import QueryField, { TypeaheadInput } from 'app/features/explore/QueryField';
 import { PromQuery, PromContext, PromOptions } from '../types';
 import { CancelablePromise, makePromiseCancelable } from 'app/core/utils/CancelablePromise';
 import { ExploreQueryFieldProps, DataSourceStatus, QueryHint, DOMUtil } from '@grafana/ui';
 import { isDataFrame, toLegacyResponseData } from '@grafana/data';
 import { PrometheusDatasource } from '../datasource';
+import PromQlLanguageProvider from '../language_provider';
+import { SuggestionsState } from 'app/features/explore/slate-plugins/suggestions';
 
 const HISTOGRAM_GROUP = '__histograms__';
 const METRIC_MARK = 'metric';
@@ -67,7 +69,7 @@ export function groupMetricsByPrefix(metrics: string[], delimiter = '_'): Cascad
   return [...options, ...metricsOptions];
 }
 
-export function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: QueryFieldState): string {
+export function willApplySuggestion(suggestion: string, { typeaheadContext, typeaheadText }: SuggestionsState): string {
   // Modify suggestion based on context
   switch (typeaheadContext) {
     case 'context-labels': {
@@ -102,7 +104,7 @@ interface CascaderOption {
 }
 
 interface PromQueryFieldProps extends ExploreQueryFieldProps<PrometheusDatasource, PromQuery, PromOptions> {
-  history: HistoryItem[];
+  history: Array<HistoryItem<PromQuery>>;
 }
 
 interface PromQueryFieldState {
@@ -113,7 +115,7 @@ interface PromQueryFieldState {
 
 class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryFieldState> {
   plugins: any[];
-  languageProvider: any;
+  languageProvider: PromQlLanguageProvider;
   languageProviderInitializationPromise: CancelablePromise<any>;
 
   constructor(props: PromQueryFieldProps, context: React.Context<any>) {
@@ -125,7 +127,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
 
     this.plugins = [
       BracesPlugin(),
-      PluginPrism({
+      SlatePrism({
         onlyIn: (node: any) => node.type === 'code_block',
         getSyntax: (node: any) => 'promql',
       }),
@@ -252,7 +254,7 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
       return;
     }
 
-    Prism.languages[PRISM_SYNTAX] = this.languageProvider.getSyntax();
+    Prism.languages[PRISM_SYNTAX] = this.languageProvider.syntax;
     Prism.languages[PRISM_SYNTAX][METRIC_MARK] = {
       alias: 'variable',
       pattern: new RegExp(`(?:^|\\s)(${metrics.join('|')})(?:$|\\s)`),
@@ -272,26 +274,20 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
     this.setState({ metricsOptions, syntaxLoaded: true });
   };
 
-  onTypeahead = (typeahead: TypeaheadInput): TypeaheadOutput => {
+  onTypeahead = async (typeahead: TypeaheadInput): Promise<TypeaheadOutput> => {
     if (!this.languageProvider) {
       return { suggestions: [] };
     }
 
     const { history } = this.props;
-    const { prefix, text, value, wrapperNode } = typeahead;
-
-    // Get DOM-dependent context
-    const wrapperClasses = Array.from(wrapperNode.classList);
-    const labelKeyNode = DOMUtil.getPreviousCousin(wrapperNode, '.attr-name');
-    const labelKey = labelKeyNode && labelKeyNode.textContent;
-    const nextChar = DOMUtil.getNextCharacter();
+    const { prefix, text, value, wrapperClasses, labelKey } = typeahead;
 
-    const result = this.languageProvider.provideCompletionItems(
+    const result = await this.languageProvider.provideCompletionItems(
       { text, value, prefix, wrapperClasses, labelKey },
       { history }
     );
 
-    console.log('handleTypeahead', wrapperClasses, text, prefix, nextChar, labelKey, result.context);
+    // console.log('handleTypeahead', wrapperClasses, text, prefix, labelKey, result.context);
 
     return result;
   };

+ 85 - 60
public/app/plugins/datasource/prometheus/language_provider.ts

@@ -1,23 +1,28 @@
 import _ from 'lodash';
 
+import { dateTime } from '@grafana/data';
+
 import {
   CompletionItem,
   CompletionItemGroup,
   LanguageProvider,
   TypeaheadInput,
   TypeaheadOutput,
+  HistoryItem,
 } from 'app/types/explore';
 
 import { parseSelector, processLabels, processHistogramLabels } from './language_utils';
 import PromqlSyntax, { FUNCTIONS, RATE_RANGES } from './promql';
-import { dateTime } from '@grafana/data';
+
+import { PrometheusDatasource } from './datasource';
+import { PromQuery } from './types';
 
 const DEFAULT_KEYS = ['job', 'instance'];
 const EMPTY_SELECTOR = '{}';
 const HISTORY_ITEM_COUNT = 5;
 const HISTORY_COUNT_CUTOFF = 1000 * 60 * 60 * 24; // 24h
 
-const wrapLabel = (label: string) => ({ label });
+const wrapLabel = (label: string): CompletionItem => ({ label });
 
 const setFunctionKind = (suggestion: CompletionItem): CompletionItem => {
   suggestion.kind = 'function';
@@ -30,10 +35,12 @@ export function addHistoryMetadata(item: CompletionItem, history: any[]): Comple
   const count = historyForItem.length;
   const recent = historyForItem[0];
   let hint = `Queried ${count} times in the last 24h.`;
+
   if (recent) {
     const lastQueried = dateTime(recent.ts).fromNow();
     hint = `${hint} Last queried ${lastQueried}.`;
   }
+
   return {
     ...item,
     documentation: hint,
@@ -47,8 +54,9 @@ export default class PromQlLanguageProvider extends LanguageProvider {
   labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
   metrics?: string[];
   startTask: Promise<any>;
+  datasource: PrometheusDatasource;
 
-  constructor(datasource: any, initialValues?: any) {
+  constructor(datasource: PrometheusDatasource, initialValues?: any) {
     super();
 
     this.datasource = datasource;
@@ -60,10 +68,11 @@ export default class PromQlLanguageProvider extends LanguageProvider {
 
     Object.assign(this, initialValues);
   }
+
   // Strip syntax chars
   cleanText = (s: string) => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
 
-  getSyntax() {
+  get syntax() {
     return PromqlSyntax;
   }
 
@@ -106,39 +115,46 @@ export default class PromQlLanguageProvider extends LanguageProvider {
     }
   };
 
-  // Keep this DOM-free for testing
-  provideCompletionItems({ prefix, wrapperClasses, text, value }: TypeaheadInput, context?: any): TypeaheadOutput {
+  provideCompletionItems = async (
+    { prefix, text, value, labelKey, wrapperClasses }: TypeaheadInput,
+    context: { history: Array<HistoryItem<PromQuery>> } = { history: [] }
+  ): Promise<TypeaheadOutput> => {
     // Local text properties
     const empty = value.document.text.length === 0;
-    const selectedLines = value.document.getTextsAtRangeAsArray(value.selection);
-    const currentLine = selectedLines.length === 1 ? selectedLines[0] : null;
-    const nextCharacter = currentLine ? currentLine.text[value.selection.anchorOffset] : null;
+    const selectedLines = value.document.getTextsAtRange(value.selection);
+    const currentLine = selectedLines.size === 1 ? selectedLines.first().getText() : null;
+
+    const nextCharacter = currentLine ? currentLine[value.selection.anchor.offset] : null;
 
     // Syntax spans have 3 classes by default. More indicate a recognized token
     const tokenRecognized = wrapperClasses.length > 3;
     // Non-empty prefix, but not inside known token
     const prefixUnrecognized = prefix && !tokenRecognized;
+
     // Prevent suggestions in `function(|suffix)`
     const noSuffix = !nextCharacter || nextCharacter === ')';
-    // Empty prefix is safe if it does not immediately folllow a complete expression and has no text after it
+
+    // Empty prefix is safe if it does not immediately follow a complete expression and has no text after it
     const safeEmptyPrefix = prefix === '' && !text.match(/^[\]})\s]+$/) && noSuffix;
+
     // About to type next operand if preceded by binary operator
-    const isNextOperand = text.match(/[+\-*/^%]/);
+    const operatorsPattern = /[+\-*/^%]/;
+    const isNextOperand = text.match(operatorsPattern);
 
     // Determine candidates by CSS context
-    if (_.includes(wrapperClasses, 'context-range')) {
+    if (wrapperClasses.includes('context-range')) {
       // Suggestions for metric[|]
       return this.getRangeCompletionItems();
-    } else if (_.includes(wrapperClasses, 'context-labels')) {
+    } else if (wrapperClasses.includes('context-labels')) {
       // Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
-      return this.getLabelCompletionItems.apply(this, arguments);
-    } else if (_.includes(wrapperClasses, 'context-aggregation')) {
+      return this.getLabelCompletionItems({ prefix, text, value, labelKey, wrapperClasses });
+    } else if (wrapperClasses.includes('context-aggregation')) {
       // Suggestions for sum(metric) by (|)
-      return this.getAggregationCompletionItems.apply(this, arguments);
+      return this.getAggregationCompletionItems({ prefix, text, value, labelKey, wrapperClasses });
     } else if (empty) {
       // Suggestions for empty query field
-      return this.getEmptyCompletionItems(context || {});
-    } else if (prefixUnrecognized || safeEmptyPrefix || isNextOperand) {
+      return this.getEmptyCompletionItems(context);
+    } else if ((prefixUnrecognized && noSuffix) || safeEmptyPrefix || isNextOperand) {
       // Show term suggestions in a couple of scenarios
       return this.getTermCompletionItems();
     }
@@ -146,20 +162,20 @@ export default class PromQlLanguageProvider extends LanguageProvider {
     return {
       suggestions: [],
     };
-  }
+  };
 
-  getEmptyCompletionItems(context: any): TypeaheadOutput {
+  getEmptyCompletionItems = (context: { history: Array<HistoryItem<PromQuery>> }): TypeaheadOutput => {
     const { history } = context;
-    let suggestions: CompletionItemGroup[] = [];
+    const suggestions = [];
 
-    if (history && history.length > 0) {
+    if (history && history.length) {
       const historyItems = _.chain(history)
-        .map((h: any) => h.query.expr)
+        .map(h => h.query.expr)
         .filter()
         .uniq()
         .take(HISTORY_ITEM_COUNT)
         .map(wrapLabel)
-        .map((item: CompletionItem) => addHistoryMetadata(item, history))
+        .map(item => addHistoryMetadata(item, history))
         .value();
 
       suggestions.push({
@@ -171,14 +187,14 @@ export default class PromQlLanguageProvider extends LanguageProvider {
     }
 
     const termCompletionItems = this.getTermCompletionItems();
-    suggestions = [...suggestions, ...termCompletionItems.suggestions];
+    suggestions.push(...termCompletionItems.suggestions);
 
     return { suggestions };
-  }
+  };
 
-  getTermCompletionItems(): TypeaheadOutput {
+  getTermCompletionItems = (): TypeaheadOutput => {
     const { metrics } = this;
-    const suggestions: CompletionItemGroup[] = [];
+    const suggestions = [];
 
     suggestions.push({
       prefixMatch: true,
@@ -186,14 +202,15 @@ export default class PromQlLanguageProvider extends LanguageProvider {
       items: FUNCTIONS.map(setFunctionKind),
     });
 
-    if (metrics && metrics.length > 0) {
+    if (metrics && metrics.length) {
       suggestions.push({
         label: 'Metrics',
         items: metrics.map(wrapLabel),
       });
     }
+
     return { suggestions };
-  }
+  };
 
   getRangeCompletionItems(): TypeaheadOutput {
     return {
@@ -219,21 +236,21 @@ export default class PromQlLanguageProvider extends LanguageProvider {
     );
   }
 
-  getAggregationCompletionItems({ value }: TypeaheadInput): TypeaheadOutput {
+  getAggregationCompletionItems = ({ value }: TypeaheadInput): TypeaheadOutput => {
     const refresher: Promise<any> = null;
     const suggestions: CompletionItemGroup[] = [];
 
     // Stitch all query lines together to support multi-line queries
     let queryOffset;
-    const queryText = value.document.getBlocks().reduce((text: string, block: any) => {
+    const queryText = value.document.getBlocks().reduce((text: string, block) => {
       const blockText = block.getText();
       if (value.anchorBlock.key === block.key) {
         // Newline characters are not accounted for but this is irrelevant
         // for the purpose of extracting the selector string
-        queryOffset = value.anchorOffset + text.length;
+        queryOffset = value.selection.anchor.offset + text.length;
       }
-      text += blockText;
-      return text;
+
+      return text + blockText;
     }, '');
 
     // Try search for selector part on the left-hand side, such as `sum (m) by (l)`
@@ -259,10 +276,10 @@ export default class PromQlLanguageProvider extends LanguageProvider {
       return result;
     }
 
-    let selectorString = queryText.slice(openParensSelectorIndex + 1, closeParensSelectorIndex);
-
     // Range vector syntax not accounted for by subsequent parse so discard it if present
-    selectorString = selectorString.replace(/\[[^\]]+\]$/, '');
+    const selectorString = queryText
+      .slice(openParensSelectorIndex + 1, closeParensSelectorIndex)
+      .replace(/\[[^\]]+\]$/, '');
 
     const selector = parseSelector(selectorString, selectorString.length - 2).selector;
 
@@ -274,14 +291,16 @@ export default class PromQlLanguageProvider extends LanguageProvider {
     }
 
     return result;
-  }
+  };
 
-  getLabelCompletionItems({ text, wrapperClasses, labelKey, value }: TypeaheadInput): TypeaheadOutput {
-    let context: string;
-    let refresher: Promise<any> = null;
-    const suggestions: CompletionItemGroup[] = [];
+  getLabelCompletionItems = async ({
+    text,
+    wrapperClasses,
+    labelKey,
+    value,
+  }: TypeaheadInput): Promise<TypeaheadOutput> => {
     const line = value.anchorBlock.getText();
-    const cursorOffset: number = value.anchorOffset;
+    const cursorOffset = value.selection.anchor.offset;
 
     // Get normalized selector
     let selector;
@@ -292,10 +311,23 @@ export default class PromQlLanguageProvider extends LanguageProvider {
     } catch {
       selector = EMPTY_SELECTOR;
     }
-    const containsMetric = selector.indexOf('__name__=') > -1;
+
+    const containsMetric = selector.includes('__name__=');
     const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
 
-    if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
+    // Query labels for selector
+    if (selector && (!this.labelValues[selector] || this.timeRangeChanged())) {
+      if (selector === EMPTY_SELECTOR) {
+        // Query label values for default labels
+        await Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key)));
+      } else {
+        await this.fetchSeriesLabels(selector, !containsMetric);
+      }
+    }
+
+    const suggestions = [];
+    let context: string;
+    if ((text && text.match(/^!?=~?/)) || wrapperClasses.includes('attr-value')) {
       // Label values
       if (labelKey && this.labelValues[selector] && this.labelValues[selector][labelKey]) {
         const labelValues = this.labelValues[selector][labelKey];
@@ -308,27 +340,20 @@ export default class PromQlLanguageProvider extends LanguageProvider {
     } else {
       // Label keys
       const labelKeys = this.labelKeys[selector] || (containsMetric ? null : DEFAULT_KEYS);
+
       if (labelKeys) {
         const possibleKeys = _.difference(labelKeys, existingKeys);
-        if (possibleKeys.length > 0) {
+        if (possibleKeys.length) {
           context = 'context-labels';
-          suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
+          const newItems = possibleKeys.map(key => ({ label: key }));
+          const newSuggestion: CompletionItemGroup = { label: `Labels`, items: newItems };
+          suggestions.push(newSuggestion);
         }
       }
     }
 
-    // Query labels for selector
-    if (selector && (!this.labelValues[selector] || this.timeRangeChanged())) {
-      if (selector === EMPTY_SELECTOR) {
-        // Query label values for default labels
-        refresher = Promise.all(DEFAULT_KEYS.map(key => this.fetchLabelValues(key)));
-      } else {
-        refresher = this.fetchSeriesLabels(selector, !containsMetric);
-      }
-    }
-
-    return { context, refresher, suggestions };
-  }
+    return { context, suggestions };
+  };
 
   fetchLabelValues = async (key: string) => {
     try {

+ 4 - 4
public/app/plugins/datasource/prometheus/language_utils.ts

@@ -16,13 +16,13 @@ export const processHistogramLabels = (labels: string[]) => {
   return { values: { __name__: result } };
 };
 
-export function processLabels(labels: any, withName = false) {
+export function processLabels(labels: Array<{ [key: string]: string }>, withName = false) {
   const values: { [key: string]: string[] } = {};
-  labels.forEach((l: any) => {
+  labels.forEach(l => {
     const { __name__, ...rest } = l;
     if (withName) {
       values['__name__'] = values['__name__'] || [];
-      if (values['__name__'].indexOf(__name__) === -1) {
+      if (!values['__name__'].includes(__name__)) {
         values['__name__'].push(__name__);
       }
     }
@@ -31,7 +31,7 @@ export function processLabels(labels: any, withName = false) {
       if (!values[key]) {
         values[key] = [];
       }
-      if (values[key].indexOf(rest[key]) === -1) {
+      if (!values[key].includes(rest[key])) {
         values[key].push(rest[key]);
       }
     });

+ 111 - 111
public/app/plugins/datasource/prometheus/specs/language_provider.test.ts

@@ -1,21 +1,22 @@
-// @ts-ignore
 import Plain from 'slate-plain-serializer';
-
+import { Editor as SlateEditor } from 'slate';
 import LanguageProvider from '../language_provider';
+import { PrometheusDatasource } from '../datasource';
+import { HistoryItem } from 'app/types';
+import { PromQuery } from '../types';
 
 describe('Language completion provider', () => {
-  const datasource = {
+  const datasource: PrometheusDatasource = ({
     metadataRequest: () => ({ data: { data: [] as any[] } }),
     getTimeRange: () => ({ start: 0, end: 1 }),
-  };
+  } as any) as PrometheusDatasource;
 
   describe('empty query suggestions', () => {
-    it('returns default suggestions on emtpty context', () => {
+    it('returns default suggestions on empty context', async () => {
       const instance = new LanguageProvider(datasource);
       const value = Plain.deserialize('');
-      const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] });
+      const result = await instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] });
       expect(result.context).toBeUndefined();
-      expect(result.refresher).toBeUndefined();
       expect(result.suggestions).toMatchObject([
         {
           label: 'Functions',
@@ -23,12 +24,11 @@ describe('Language completion provider', () => {
       ]);
     });
 
-    it('returns default suggestions with metrics on emtpty context when metrics were provided', () => {
+    it('returns default suggestions with metrics on empty context when metrics were provided', async () => {
       const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] });
       const value = Plain.deserialize('');
-      const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] });
+      const result = await instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] });
       expect(result.context).toBeUndefined();
-      expect(result.refresher).toBeUndefined();
       expect(result.suggestions).toMatchObject([
         {
           label: 'Functions',
@@ -39,17 +39,21 @@ describe('Language completion provider', () => {
       ]);
     });
 
-    it('returns default suggestions with history on emtpty context when history was provided', () => {
+    it('returns default suggestions with history on empty context when history was provided', async () => {
       const instance = new LanguageProvider(datasource);
       const value = Plain.deserialize('');
-      const history = [
+      const history: Array<HistoryItem<PromQuery>> = [
         {
+          ts: 0,
           query: { refId: '1', expr: 'metric' },
         },
       ];
-      const result = instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] }, { history });
+      const result = await instance.provideCompletionItems(
+        { text: '', prefix: '', value, wrapperClasses: [] },
+        { history }
+      );
       expect(result.context).toBeUndefined();
-      expect(result.refresher).toBeUndefined();
+
       expect(result.suggestions).toMatchObject([
         {
           label: 'History',
@@ -67,17 +71,16 @@ describe('Language completion provider', () => {
   });
 
   describe('range suggestions', () => {
-    it('returns range suggestions in range context', () => {
+    it('returns range suggestions in range context', async () => {
       const instance = new LanguageProvider(datasource);
       const value = Plain.deserialize('1');
-      const result = instance.provideCompletionItems({
+      const result = await instance.provideCompletionItems({
         text: '1',
         prefix: '1',
         value,
         wrapperClasses: ['context-range'],
       });
       expect(result.context).toBe('context-range');
-      expect(result.refresher).toBeUndefined();
       expect(result.suggestions).toMatchObject([
         {
           items: [
@@ -96,12 +99,12 @@ describe('Language completion provider', () => {
   });
 
   describe('metric suggestions', () => {
-    it('returns metrics and function suggestions in an unknown context', () => {
+    it('returns metrics and function suggestions in an unknown context', async () => {
       const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] });
-      const value = Plain.deserialize('a');
-      const result = instance.provideCompletionItems({ text: 'a', prefix: 'a', value, wrapperClasses: [] });
+      let value = Plain.deserialize('a');
+      value = value.setSelection({ anchor: { offset: 1 }, focus: { offset: 1 } });
+      const result = await instance.provideCompletionItems({ text: 'a', prefix: 'a', value, wrapperClasses: [] });
       expect(result.context).toBeUndefined();
-      expect(result.refresher).toBeUndefined();
       expect(result.suggestions).toMatchObject([
         {
           label: 'Functions',
@@ -112,12 +115,11 @@ describe('Language completion provider', () => {
       ]);
     });
 
-    it('returns metrics and function  suggestions after a binary operator', () => {
+    it('returns metrics and function  suggestions after a binary operator', async () => {
       const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] });
       const value = Plain.deserialize('*');
-      const result = instance.provideCompletionItems({ text: '*', prefix: '', value, wrapperClasses: [] });
+      const result = await instance.provideCompletionItems({ text: '*', prefix: '', value, wrapperClasses: [] });
       expect(result.context).toBeUndefined();
-      expect(result.refresher).toBeUndefined();
       expect(result.suggestions).toMatchObject([
         {
           label: 'Functions',
@@ -128,34 +130,30 @@ describe('Language completion provider', () => {
       ]);
     });
 
-    it('returns no suggestions at the beginning of a non-empty function', () => {
+    it('returns no suggestions at the beginning of a non-empty function', async () => {
       const instance = new LanguageProvider(datasource, { metrics: ['foo', 'bar'] });
       const value = Plain.deserialize('sum(up)');
-      const range = value.selection.merge({
-        anchorOffset: 4,
-      });
-      const valueWithSelection = value.change().select(range).value;
-      const result = instance.provideCompletionItems({
+      const ed = new SlateEditor({ value });
+
+      const valueWithSelection = ed.moveForward(4).value;
+      const result = await instance.provideCompletionItems({
         text: '',
         prefix: '',
         value: valueWithSelection,
         wrapperClasses: [],
       });
       expect(result.context).toBeUndefined();
-      expect(result.refresher).toBeUndefined();
       expect(result.suggestions.length).toEqual(0);
     });
   });
 
   describe('label suggestions', () => {
-    it('returns default label suggestions on label context and no metric', () => {
+    it('returns default label suggestions on label context and no metric', async () => {
       const instance = new LanguageProvider(datasource);
       const value = Plain.deserialize('{}');
-      const range = value.selection.merge({
-        anchorOffset: 1,
-      });
-      const valueWithSelection = value.change().select(range).value;
-      const result = instance.provideCompletionItems({
+      const ed = new SlateEditor({ value });
+      const valueWithSelection = ed.moveForward(1).value;
+      const result = await instance.provideCompletionItems({
         text: '',
         prefix: '',
         wrapperClasses: ['context-labels'],
@@ -165,14 +163,16 @@ describe('Language completion provider', () => {
       expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'instance' }], label: 'Labels' }]);
     });
 
-    it('returns label suggestions on label context and metric', () => {
-      const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } });
+    it('returns label suggestions on label context and metric', async () => {
+      const datasources: PrometheusDatasource = ({
+        metadataRequest: () => ({ data: { data: [{ __name__: 'metric', bar: 'bazinga' }] as any[] } }),
+        getTimeRange: () => ({ start: 0, end: 1 }),
+      } as any) as PrometheusDatasource;
+      const instance = new LanguageProvider(datasources, { labelKeys: { '{__name__="metric"}': ['bar'] } });
       const value = Plain.deserialize('metric{}');
-      const range = value.selection.merge({
-        anchorOffset: 7,
-      });
-      const valueWithSelection = value.change().select(range).value;
-      const result = instance.provideCompletionItems({
+      const ed = new SlateEditor({ value });
+      const valueWithSelection = ed.moveForward(7).value;
+      const result = await instance.provideCompletionItems({
         text: '',
         prefix: '',
         wrapperClasses: ['context-labels'],
@@ -182,16 +182,32 @@ describe('Language completion provider', () => {
       expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
     });
 
-    it('returns label suggestions on label context but leaves out labels that already exist', () => {
-      const instance = new LanguageProvider(datasource, {
-        labelKeys: { '{job1="foo",job2!="foo",job3=~"foo"}': ['bar', 'job1', 'job2', 'job3'] },
-      });
-      const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}');
-      const range = value.selection.merge({
-        anchorOffset: 36,
+    it('returns label suggestions on label context but leaves out labels that already exist', async () => {
+      const datasources: PrometheusDatasource = ({
+        metadataRequest: () => ({
+          data: {
+            data: [
+              {
+                __name__: 'metric',
+                bar: 'asdasd',
+                job1: 'dsadsads',
+                job2: 'fsfsdfds',
+                job3: 'dsadsad',
+              },
+            ],
+          },
+        }),
+        getTimeRange: () => ({ start: 0, end: 1 }),
+      } as any) as PrometheusDatasource;
+      const instance = new LanguageProvider(datasources, {
+        labelKeys: {
+          '{job1="foo",job2!="foo",job3=~"foo",__name__="metric"}': ['bar', 'job1', 'job2', 'job3', '__name__'],
+        },
       });
-      const valueWithSelection = value.change().select(range).value;
-      const result = instance.provideCompletionItems({
+      const value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",__name__="metric",}');
+      const ed = new SlateEditor({ value });
+      const valueWithSelection = ed.moveForward(54).value;
+      const result = await instance.provideCompletionItems({
         text: '',
         prefix: '',
         wrapperClasses: ['context-labels'],
@@ -201,15 +217,15 @@ describe('Language completion provider', () => {
       expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
     });
 
-    it('returns label value suggestions inside a label value context after a negated matching operator', () => {
+    it('returns label value suggestions inside a label value context after a negated matching operator', async () => {
       const instance = new LanguageProvider(datasource, {
         labelKeys: { '{}': ['label'] },
         labelValues: { '{}': { label: ['a', 'b', 'c'] } },
       });
       const value = Plain.deserialize('{label!=}');
-      const range = value.selection.merge({ anchorOffset: 8 });
-      const valueWithSelection = value.change().select(range).value;
-      const result = instance.provideCompletionItems({
+      const ed = new SlateEditor({ value });
+      const valueWithSelection = ed.moveForward(8).value;
+      const result = await instance.provideCompletionItems({
         text: '!=',
         prefix: '',
         wrapperClasses: ['context-labels'],
@@ -225,35 +241,30 @@ describe('Language completion provider', () => {
       ]);
     });
 
-    it('returns a refresher on label context and unavailable metric', () => {
+    it('returns a refresher on label context and unavailable metric', async () => {
       const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="foo"}': ['bar'] } });
       const value = Plain.deserialize('metric{}');
-      const range = value.selection.merge({
-        anchorOffset: 7,
-      });
-      const valueWithSelection = value.change().select(range).value;
-      const result = instance.provideCompletionItems({
+      const ed = new SlateEditor({ value });
+      const valueWithSelection = ed.moveForward(7).value;
+      const result = await instance.provideCompletionItems({
         text: '',
         prefix: '',
         wrapperClasses: ['context-labels'],
         value: valueWithSelection,
       });
       expect(result.context).toBeUndefined();
-      expect(result.refresher).toBeInstanceOf(Promise);
       expect(result.suggestions).toEqual([]);
     });
 
-    it('returns label values on label context when given a metric and a label key', () => {
+    it('returns label values on label context when given a metric and a label key', async () => {
       const instance = new LanguageProvider(datasource, {
         labelKeys: { '{__name__="metric"}': ['bar'] },
         labelValues: { '{__name__="metric"}': { bar: ['baz'] } },
       });
       const value = Plain.deserialize('metric{bar=ba}');
-      const range = value.selection.merge({
-        anchorOffset: 13,
-      });
-      const valueWithSelection = value.change().select(range).value;
-      const result = instance.provideCompletionItems({
+      const ed = new SlateEditor({ value });
+      const valueWithSelection = ed.moveForward(13).value;
+      const result = await instance.provideCompletionItems({
         text: '=ba',
         prefix: 'ba',
         wrapperClasses: ['context-labels'],
@@ -264,14 +275,12 @@ describe('Language completion provider', () => {
       expect(result.suggestions).toEqual([{ items: [{ label: 'baz' }], label: 'Label values for "bar"' }]);
     });
 
-    it('returns label suggestions on aggregation context and metric w/ selector', () => {
+    it('returns label suggestions on aggregation context and metric w/ selector', async () => {
       const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric",foo="xx"}': ['bar'] } });
       const value = Plain.deserialize('sum(metric{foo="xx"}) by ()');
-      const range = value.selection.merge({
-        anchorOffset: 26,
-      });
-      const valueWithSelection = value.change().select(range).value;
-      const result = instance.provideCompletionItems({
+      const ed = new SlateEditor({ value });
+      const valueWithSelection = ed.moveForward(26).value;
+      const result = await instance.provideCompletionItems({
         text: '',
         prefix: '',
         wrapperClasses: ['context-aggregation'],
@@ -281,14 +290,12 @@ describe('Language completion provider', () => {
       expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
     });
 
-    it('returns label suggestions on aggregation context and metric w/o selector', () => {
+    it('returns label suggestions on aggregation context and metric w/o selector', async () => {
       const instance = new LanguageProvider(datasource, { labelKeys: { '{__name__="metric"}': ['bar'] } });
       const value = Plain.deserialize('sum(metric) by ()');
-      const range = value.selection.merge({
-        anchorOffset: 16,
-      });
-      const valueWithSelection = value.change().select(range).value;
-      const result = instance.provideCompletionItems({
+      const ed = new SlateEditor({ value });
+      const valueWithSelection = ed.moveForward(16).value;
+      const result = await instance.provideCompletionItems({
         text: '',
         prefix: '',
         wrapperClasses: ['context-aggregation'],
@@ -298,15 +305,16 @@ describe('Language completion provider', () => {
       expect(result.suggestions).toEqual([{ items: [{ label: 'bar' }], label: 'Labels' }]);
     });
 
-    it('returns label suggestions inside a multi-line aggregation context', () => {
+    it('returns label suggestions inside a multi-line aggregation context', async () => {
       const instance = new LanguageProvider(datasource, {
         labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] },
       });
       const value = Plain.deserialize('sum(\nmetric\n)\nby ()');
-      const aggregationTextBlock = value.document.getBlocksAsArray()[3];
-      const range = value.selection.moveToStartOf(aggregationTextBlock).merge({ anchorOffset: 4 });
-      const valueWithSelection = value.change().select(range).value;
-      const result = instance.provideCompletionItems({
+      const aggregationTextBlock = value.document.getBlocks().get(3);
+      const ed = new SlateEditor({ value });
+      ed.moveToStartOfNode(aggregationTextBlock);
+      const valueWithSelection = ed.moveForward(4).value;
+      const result = await instance.provideCompletionItems({
         text: '',
         prefix: '',
         wrapperClasses: ['context-aggregation'],
@@ -321,16 +329,14 @@ describe('Language completion provider', () => {
       ]);
     });
 
-    it('returns label suggestions inside an aggregation context with a range vector', () => {
+    it('returns label suggestions inside an aggregation context with a range vector', async () => {
       const instance = new LanguageProvider(datasource, {
         labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] },
       });
       const value = Plain.deserialize('sum(rate(metric[1h])) by ()');
-      const range = value.selection.merge({
-        anchorOffset: 26,
-      });
-      const valueWithSelection = value.change().select(range).value;
-      const result = instance.provideCompletionItems({
+      const ed = new SlateEditor({ value });
+      const valueWithSelection = ed.moveForward(26).value;
+      const result = await instance.provideCompletionItems({
         text: '',
         prefix: '',
         wrapperClasses: ['context-aggregation'],
@@ -345,16 +351,14 @@ describe('Language completion provider', () => {
       ]);
     });
 
-    it('returns label suggestions inside an aggregation context with a range vector and label', () => {
+    it('returns label suggestions inside an aggregation context with a range vector and label', async () => {
       const instance = new LanguageProvider(datasource, {
         labelKeys: { '{__name__="metric",label1="value"}': ['label1', 'label2', 'label3'] },
       });
       const value = Plain.deserialize('sum(rate(metric{label1="value"}[1h])) by ()');
-      const range = value.selection.merge({
-        anchorOffset: 42,
-      });
-      const valueWithSelection = value.change().select(range).value;
-      const result = instance.provideCompletionItems({
+      const ed = new SlateEditor({ value });
+      const valueWithSelection = ed.moveForward(42).value;
+      const result = await instance.provideCompletionItems({
         text: '',
         prefix: '',
         wrapperClasses: ['context-aggregation'],
@@ -369,16 +373,14 @@ describe('Language completion provider', () => {
       ]);
     });
 
-    it('returns no suggestions inside an unclear aggregation context using alternate syntax', () => {
+    it('returns no suggestions inside an unclear aggregation context using alternate syntax', async () => {
       const instance = new LanguageProvider(datasource, {
         labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] },
       });
       const value = Plain.deserialize('sum by ()');
-      const range = value.selection.merge({
-        anchorOffset: 8,
-      });
-      const valueWithSelection = value.change().select(range).value;
-      const result = instance.provideCompletionItems({
+      const ed = new SlateEditor({ value });
+      const valueWithSelection = ed.moveForward(8).value;
+      const result = await instance.provideCompletionItems({
         text: '',
         prefix: '',
         wrapperClasses: ['context-aggregation'],
@@ -388,16 +390,14 @@ describe('Language completion provider', () => {
       expect(result.suggestions).toEqual([]);
     });
 
-    it('returns label suggestions inside an aggregation context using alternate syntax', () => {
+    it('returns label suggestions inside an aggregation context using alternate syntax', async () => {
       const instance = new LanguageProvider(datasource, {
         labelKeys: { '{__name__="metric"}': ['label1', 'label2', 'label3'] },
       });
       const value = Plain.deserialize('sum by () (metric)');
-      const range = value.selection.merge({
-        anchorOffset: 8,
-      });
-      const valueWithSelection = value.change().select(range).value;
-      const result = instance.provideCompletionItems({
+      const ed = new SlateEditor({ value });
+      const valueWithSelection = ed.moveForward(8).value;
+      const result = await instance.provideCompletionItems({
         text: '',
         prefix: '',
         wrapperClasses: ['context-aggregation'],

+ 25 - 7
public/app/types/explore.ts

@@ -23,11 +23,18 @@ import {
 import { Emitter } from 'app/core/core';
 import TableModel from 'app/core/table_model';
 
+import { Value } from 'slate';
+
+import { Editor } from '@grafana/slate-react';
 export enum ExploreMode {
   Metrics = 'Metrics',
   Logs = 'Logs',
 }
 
+export enum CompletionItemKind {
+  GroupTitle = 'GroupTitle',
+}
+
 export interface CompletionItem {
   /**
    * The label of this completion item. By default
@@ -35,40 +42,48 @@ export interface CompletionItem {
    * this completion.
    */
   label: string;
+
   /**
-   * The kind of this completion item. Based on the kind
-   * an icon is chosen by the editor.
+   * The kind of this completion item. An icon is chosen
+   * by the editor based on the kind.
    */
-  kind?: string;
+  kind?: CompletionItemKind | string;
+
   /**
    * A human-readable string with additional information
    * about this item, like type or symbol information.
    */
   detail?: string;
+
   /**
    * A human-readable string, can be Markdown, that represents a doc-comment.
    */
   documentation?: string;
+
   /**
    * A string that should be used when comparing this item
    * with other items. When `falsy` the `label` is used.
    */
   sortText?: string;
+
   /**
    * A string that should be used when filtering a set of
    * completion items. When `falsy` the `label` is used.
    */
   filterText?: string;
+
   /**
    * A string or snippet that should be inserted in a document when selecting
    * this completion. When `falsy` the `label` is used.
    */
   insertText?: string;
+
   /**
    * Delete number of characters before the caret position,
    * by default the letters from the beginning of the word.
    */
   deleteBackwards?: number;
+
   /**
    * Number of steps to move after the insertion, can be negative.
    */
@@ -80,18 +95,22 @@ export interface CompletionItemGroup {
    * Label that will be displayed for all entries of this group.
    */
   label: string;
+
   /**
    * List of suggestions of this group.
    */
   items: CompletionItem[];
+
   /**
    * If true, match only by prefix (and not mid-word).
    */
   prefixMatch?: boolean;
+
   /**
    * If true, do not filter items in this group based on the search.
    */
   skipFilter?: boolean;
+
   /**
    * If true, do not sort items.
    */
@@ -294,7 +313,7 @@ export interface HistoryItem<TQuery extends DataQuery = DataQuery> {
 }
 
 export abstract class LanguageProvider {
-  datasource: any;
+  datasource: DataSourceApi;
   request: (url: string, params?: any) => Promise<any>;
   /**
    * Returns startTask that resolves with a task list when main syntax is loaded.
@@ -309,13 +328,12 @@ export interface TypeaheadInput {
   prefix: string;
   wrapperClasses: string[];
   labelKey?: string;
-  //Should be Value from slate
-  value?: any;
+  value?: Value;
+  editor?: Editor;
 }
 
 export interface TypeaheadOutput {
   context?: string;
-  refresher?: Promise<{}>;
   suggestions: CompletionItemGroup[];
 }
 

+ 4 - 4
public/sass/components/_slate_editor.scss

@@ -30,9 +30,9 @@
   .typeahead {
     position: absolute;
     z-index: auto;
-    top: -10000px;
-    left: -10000px;
-    opacity: 0;
+    top: 100px;
+    left: 160px;
+    //opacity: 0;
     border-radius: $border-radius;
     border: $panel-border;
     max-height: calc(66vh);
@@ -43,7 +43,7 @@
     list-style: none;
     background: $panel-bg;
     color: $text-color;
-    transition: opacity 0.4s ease-out;
+    //transition: opacity 0.4s ease-out;
     box-shadow: $typeahead-shadow;
   }
 

+ 2 - 1
tsconfig.json

@@ -31,7 +31,8 @@
     "typeRoots": ["node_modules/@types", "public/app/types"],
     "paths": {
       "app": ["app"],
-      "sass": ["sass"]
+      "sass": ["sass"],
+      "@grafana/slate-react": ["../node_modules/@types/slate-react"]
     },
     "skipLibCheck": true,
     "preserveSymlinks": true

+ 111 - 109
yarn.lock

@@ -1227,6 +1227,28 @@
     unique-filename "^1.1.1"
     which "^1.3.1"
 
+"@grafana/slate-react@0.22.9-grafana":
+  version "0.22.9-grafana"
+  resolved "https://registry.yarnpkg.com/@grafana/slate-react/-/slate-react-0.22.9-grafana.tgz#07f35f0ffc018f616b9f82fa6e5ba65fae75c6a0"
+  integrity sha512-9NYjwabVOUQ/e4Y/Wm+sgePM65rb/gju59D52t4O42HsIm9exXv+SLajEBF/HiLHzuH5V+5uuHajbzv0vuE2VA==
+  dependencies:
+    debug "^3.1.0"
+    get-window "^1.1.1"
+    is-window "^1.0.2"
+    lodash "^4.1.1"
+    memoize-one "^4.0.0"
+    prop-types "^15.5.8"
+    react-immutable-proptypes "^2.1.0"
+    selection-is-backward "^1.0.0"
+    slate-base64-serializer "^0.2.111"
+    slate-dev-environment "^0.2.2"
+    slate-hotkeys "^0.2.9"
+    slate-plain-serializer "^0.7.10"
+    slate-prop-types "^0.5.41"
+    slate-react-placeholder "^0.2.8"
+    tiny-invariant "^1.0.1"
+    tiny-warning "^0.0.3"
+
 "@icons/material@^0.2.4":
   version "0.2.4"
   resolved "https://registry.yarnpkg.com/@icons/material/-/material-0.2.4.tgz#e90c9f71768b3736e76d7dd6783fc6c2afa88bc8"
@@ -3408,10 +3430,26 @@
   version "7.0.11"
   resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-7.0.11.tgz#6f28f005a36e779b7db0f1359b9fb9eef72aae88"
 
-"@types/slate@0.44.11":
-  version "0.44.11"
-  resolved "https://registry.yarnpkg.com/@types/slate/-/slate-0.44.11.tgz#152568096d1a089fa4c5bb03de1cf044a377206c"
-  integrity sha512-UnOGipgkE1+rq3L4JjsTO0b02FbT6b59+0/hkW/QFBDvCcxCSAdwdr9HYjXkMSCSVlcsEfdC/cz+XOaB+tGvlg==
+"@types/slate-plain-serializer@0.6.1":
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/@types/slate-plain-serializer/-/slate-plain-serializer-0.6.1.tgz#c392ce51621f7c55df0976f161dcfca18bd559ee"
+  integrity sha512-5meyKFvmWH1T02j2dbAaY8kn/FNofxP79jV3TsfuLsUIeHkON5CroBxAyrgkYF4vHp+MVWZddI36Yvwl7Y0Feg==
+  dependencies:
+    "@types/slate" "*"
+
+"@types/slate-react@0.22.5":
+  version "0.22.5"
+  resolved "https://registry.yarnpkg.com/@types/slate-react/-/slate-react-0.22.5.tgz#a10796758aa6b3133e1c777959facbf8806959f7"
+  integrity sha512-WKJic5LlNRNUCnD6lEdlOZCcXWoDN8Ais2CmwVMn8pdt5Kh8hJsTYhXawNxOShPIOLVB+G+aVZNAXAAubEOpaw==
+  dependencies:
+    "@types/react" "*"
+    "@types/slate" "*"
+    immutable "^3.8.2"
+
+"@types/slate@*", "@types/slate@0.47.1":
+  version "0.47.1"
+  resolved "https://registry.yarnpkg.com/@types/slate/-/slate-0.47.1.tgz#6c66f82df085c764039eea2229be763f7e1906fd"
+  integrity sha512-2ZlnWI6/RYMXxeGFIeZtvmaXAeYAJh4ZVumziqVl77/liNEi9hOwkUTU2zFu+j/z21v385I2WVPl8sgadxfzXg==
   dependencies:
     "@types/react" "*"
     immutable "^3.8.2"
@@ -4675,7 +4713,6 @@ bail@^1.0.0:
 balanced-match@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
-  integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
 
 baron@3.0.3:
   version "3.0.3"
@@ -4834,7 +4871,6 @@ boxen@^2.1.0:
 brace-expansion@^1.0.0, brace-expansion@^1.1.7:
   version "1.1.11"
   resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
-  integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
   dependencies:
     balanced-match "^1.0.0"
     concat-map "0.0.1"
@@ -5723,7 +5759,6 @@ compression@^1.5.2:
 concat-map@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
-  integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
 
 concat-stream@1.6.2, concat-stream@^1.4.6, concat-stream@^1.5.0:
   version "1.6.2"
@@ -7154,6 +7189,7 @@ dir-glob@^2.0.0:
 direction@^0.1.5:
   version "0.1.5"
   resolved "https://registry.yarnpkg.com/direction/-/direction-0.1.5.tgz#ce5d797f97e26f8be7beff53f7dc40e1c1a9ec4c"
+  integrity sha1-zl15f5fib4vnvv9T99xA4cGp7Ew=
 
 discontinuous-range@1.0.0:
   version "1.0.0"
@@ -7745,6 +7781,7 @@ esrecurse@^4.1.0:
 esrever@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/esrever/-/esrever-0.2.0.tgz#96e9d28f4f1b1a76784cd5d490eaae010e7407b8"
+  integrity sha1-lunSj08bGnZ4TNXUkOquAQ50B7g=
 
 estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0:
   version "4.2.0"
@@ -8288,7 +8325,6 @@ for-in@^0.1.3:
 for-in@^1.0.1, for-in@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
-  integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=
 
 for-own@^0.1.3, for-own@^0.1.4:
   version "0.1.5"
@@ -8449,7 +8485,6 @@ fs-write-stream-atomic@^1.0.8, fs-write-stream-atomic@~1.0.10:
 fs.realpath@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
-  integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
 
 fsevents@^1.2.7:
   version "1.2.9"
@@ -8902,9 +8937,8 @@ got@^6.7.1:
     url-parse-lax "^1.0.0"
 
 graceful-fs@^4.1.0, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9:
-  version "4.2.2"
-  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.2.tgz#6f0952605d0140c1cfdb138ed005775b92d67b02"
-  integrity sha512-IItsdsea19BoLC7ELy13q1iJFNmd7ofZH5+X/pJr90/nRoPEX0DJo1dHDbgtYWOhJhcCgMDTOw84RZ72q6lB+Q==
+  version "4.1.15"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00"
 
 "graceful-readlink@>= 1.0.0":
   version "1.0.1"
@@ -9634,7 +9668,6 @@ infer-owner@^1.0.4:
 inflight@^1.0.4, inflight@~1.0.6:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
-  integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
   dependencies:
     once "^1.3.0"
     wrappy "1"
@@ -9943,10 +9976,6 @@ is-dotfile@^1.0.0:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1"
 
-is-empty@^1.0.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/is-empty/-/is-empty-1.2.0.tgz#de9bb5b278738a05a0b09a57e1fb4d4a341a9f6b"
-
 is-equal-shallow@^0.1.3:
   version "0.1.3"
   resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534"
@@ -9960,7 +9989,6 @@ is-extendable@^0.1.0, is-extendable@^0.1.1:
 is-extendable@^1.0.0, is-extendable@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4"
-  integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==
   dependencies:
     is-plain-object "^2.0.4"
 
@@ -10022,7 +10050,7 @@ is-hexadecimal@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.2.tgz#b6e710d7d07bb66b98cb8cece5c9b4921deeb835"
 
-is-hotkey@0.1.4, is-hotkey@^0.1.1:
+is-hotkey@0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/is-hotkey/-/is-hotkey-0.1.4.tgz#c34d2c85d6ec8d09a871dcf71931c8067a824c7d"
 
@@ -10277,7 +10305,6 @@ isobject@^2.0.0:
 isobject@^3.0.0, isobject@^3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
-  integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
 
 isobject@^4.0.0:
   version "4.0.0"
@@ -10914,7 +10941,7 @@ kew@^0.7.0:
   version "0.7.0"
   resolved "https://registry.yarnpkg.com/kew/-/kew-0.7.0.tgz#79d93d2d33363d6fdd2970b335d9141ad591d79b"
 
-keycode@^2.1.2, keycode@^2.2.0:
+keycode@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/keycode/-/keycode-2.2.0.tgz#3d0af56dc7b8b8e5cba8d0a97f107204eec22b04"
 
@@ -11337,9 +11364,8 @@ lockfile@^1.0.4:
     signal-exit "^3.0.2"
 
 lodash-es@^4.17.11, lodash-es@^4.2.1:
-  version "4.17.15"
-  resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78"
-  integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ==
+  version "4.17.11"
+  resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.11.tgz#145ab4a7ac5c5e52a3531fb4f310255a152b4be0"
 
 lodash._baseuniq@~4.6.0:
   version "4.6.0"
@@ -11356,7 +11382,7 @@ lodash._getnative@^3.0.0:
   version "3.9.1"
   resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5"
 
-lodash._reinterpolate@^3.0.0:
+lodash._reinterpolate@^3.0.0, lodash._reinterpolate@~3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
   integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=
@@ -11427,9 +11453,8 @@ lodash.memoize@^4.1.2:
   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
 
 lodash.mergewith@^4.6.1:
-  version "4.6.2"
-  resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55"
-  integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==
+  version "4.6.1"
+  resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz#639057e726c3afbdb3e7d42741caa8d6e4335927"
 
 lodash.once@^4.1.1:
   version "4.1.1"
@@ -11452,7 +11477,7 @@ lodash.tail@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664"
 
-lodash.template@^4.0.2, lodash.template@^4.2.4:
+lodash.template@^4.0.2:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab"
   integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==
@@ -11460,12 +11485,20 @@ lodash.template@^4.0.2, lodash.template@^4.2.4:
     lodash._reinterpolate "^3.0.0"
     lodash.templatesettings "^4.0.0"
 
+lodash.template@^4.2.4:
+  version "4.4.0"
+  resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.4.0.tgz#e73a0385c8355591746e020b99679c690e68fba0"
+  integrity sha1-5zoDhcg1VZF0bgILmWecaQ5o+6A=
+  dependencies:
+    lodash._reinterpolate "~3.0.0"
+    lodash.templatesettings "^4.0.0"
+
 lodash.templatesettings@^4.0.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz#e481310f049d3cf6d47e912ad09313b154f0fb33"
-  integrity sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.1.0.tgz#2b4d4e95ba440d915ff08bc899e4553666713316"
+  integrity sha1-K01OlbpEDZFf8IvImeRVNmZxMxY=
   dependencies:
-    lodash._reinterpolate "^3.0.0"
+    lodash._reinterpolate "~3.0.0"
 
 lodash.throttle@^4.1.1:
   version "4.1.1"
@@ -11968,7 +12001,6 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1:
 "minimatch@2 || 3", minimatch@3.0.4, minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.0, minimatch@~3.0.2:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
-  integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
   dependencies:
     brace-expansion "^1.1.7"
 
@@ -11989,7 +12021,6 @@ minimist-options@^3.0.1:
 minimist@0.0.8:
   version "0.0.8"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
-  integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=
 
 minimist@1.1.x:
   version "1.1.3"
@@ -12032,9 +12063,8 @@ mississippi@^3.0.0:
     through2 "^2.0.0"
 
 mixin-deep@^1.2.0:
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
-  integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe"
   dependencies:
     for-in "^1.0.2"
     is-extendable "^1.0.1"
@@ -12935,7 +12965,6 @@ on-headers@~1.0.2:
 once@^1.3.0, once@^1.3.1, once@^1.4.0, once@~1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
-  integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
   dependencies:
     wrappy "1"
 
@@ -13358,7 +13387,6 @@ path-exists@^3.0.0:
 path-is-absolute@^1.0.0, path-is-absolute@~1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
-  integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
 
 path-is-inside@^1.0.1, path-is-inside@^1.0.2, path-is-inside@~1.0.2:
   version "1.0.2"
@@ -14347,7 +14375,7 @@ pretty-hrtime@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
 
-prismjs@1.16.0, prismjs@^1.13.0, prismjs@^1.8.4, prismjs@~1.16.0:
+prismjs@1.16.0, prismjs@^1.8.4, prismjs@~1.16.0:
   version "1.16.0"
   resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.16.0.tgz#406eb2c8aacb0f5f0f1167930cb83835d10a4308"
   optionalDependencies:
@@ -15072,12 +15100,6 @@ react-popper@^1.3.3:
     typed-styles "^0.0.7"
     warning "^4.0.2"
 
-react-portal@^3.1.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/react-portal/-/react-portal-3.2.0.tgz#4224e19b2b05d5cbe730a7ba0e34ec7585de0043"
-  dependencies:
-    prop-types "^15.5.8"
-
 react-redux@5.1.1:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.1.1.tgz#88e368682c7fa80e34e055cd7ac56f5936b0f52f"
@@ -16485,81 +16507,55 @@ slash@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44"
 
-slate-base64-serializer@^0.2.36:
-  version "0.2.102"
-  resolved "https://registry.yarnpkg.com/slate-base64-serializer/-/slate-base64-serializer-0.2.102.tgz#05cdb9149172944b55c8d0a0d14b4499a1c3b5a2"
+slate-base64-serializer@^0.2.111:
+  version "0.2.111"
+  resolved "https://registry.yarnpkg.com/slate-base64-serializer/-/slate-base64-serializer-0.2.111.tgz#22ba7d32aa4650f6bbd25c26ffe11f5d021959d6"
+  integrity sha512-pEsbxz4msVSCCCkn7rX+lHXxUj/oddcR4VsIYwWeQQLm9Uw7Ovxja4rQ/hVFcQqoU2DIjITRwBR9pv3RyS+PZQ==
   dependencies:
     isomorphic-base64 "^1.0.2"
 
-slate-dev-environment@^0.1.2, slate-dev-environment@^0.1.4:
-  version "0.1.6"
-  resolved "https://registry.yarnpkg.com/slate-dev-environment/-/slate-dev-environment-0.1.6.tgz#ff22b40ef4cc890ff7706b6b657abc276782424f"
+slate-dev-environment@^0.2.2:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/slate-dev-environment/-/slate-dev-environment-0.2.2.tgz#bd8946e1fe4cf5447060c84a362a1d026ed8b77f"
+  integrity sha512-JZ09llrRQu6JUsLJCUlGC0lB1r1qIAabAkSd454iyYBq6lDuY//Bypi3Jo8yzIfzZ4+mRLdQvl9e8MbeM9l48Q==
   dependencies:
     is-in-browser "^1.1.3"
 
-slate-dev-logger@^0.1.39, slate-dev-logger@^0.1.43:
-  version "0.1.43"
-  resolved "https://registry.yarnpkg.com/slate-dev-logger/-/slate-dev-logger-0.1.43.tgz#77f6ca7207fcbf453a5516f3aa8b19794d1d26dc"
-
-slate-hotkeys@^0.1.2:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/slate-hotkeys/-/slate-hotkeys-0.1.4.tgz#5b10b2a178affc60827f9284d4c0a5d7e5041ffe"
+slate-hotkeys@^0.2.9:
+  version "0.2.9"
+  resolved "https://registry.yarnpkg.com/slate-hotkeys/-/slate-hotkeys-0.2.9.tgz#0cc9eb750a49ab9ef11601305b7c82b5402348e3"
+  integrity sha512-y+C/s5vJEmBxo8fIqHmUcdViGwALL/A6Qow3sNG1OHYD5SI11tC2gfYtGbPh+2q0H7O4lufffCmFsP5bMaDHqA==
   dependencies:
-    is-hotkey "^0.1.1"
-    slate-dev-environment "^0.1.4"
+    is-hotkey "0.1.4"
+    slate-dev-environment "^0.2.2"
 
-slate-plain-serializer@0.5.41, slate-plain-serializer@^0.5.17:
-  version "0.5.41"
-  resolved "https://registry.yarnpkg.com/slate-plain-serializer/-/slate-plain-serializer-0.5.41.tgz#dc2d219602c2cb8dc710ac660e108f3b3cc4dc80"
-  dependencies:
-    slate-dev-logger "^0.1.43"
-
-slate-prism@0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/slate-prism/-/slate-prism-0.5.0.tgz#009eb74fea38ad76c64db67def7ea0884917adec"
-  dependencies:
-    prismjs "^1.13.0"
+slate-plain-serializer@0.7.10, slate-plain-serializer@^0.7.10:
+  version "0.7.10"
+  resolved "https://registry.yarnpkg.com/slate-plain-serializer/-/slate-plain-serializer-0.7.10.tgz#bc4a6942cf52fde826019bb1095dffd0dac8cc08"
+  integrity sha512-/QvMCQ0F3NzbnuoW+bxsLIChPdRgxBjQeGhYhpRGTVvlZCLOmfDvavhN6fHsuEwkvdwOmocNF30xT1WVlmibYg==
 
-slate-prop-types@^0.4.34:
-  version "0.4.67"
-  resolved "https://registry.yarnpkg.com/slate-prop-types/-/slate-prop-types-0.4.67.tgz#c6aa74195466546a44fcb85d1c7b15fefe36ce6b"
-
-slate-react@0.12.11:
-  version "0.12.11"
-  resolved "https://registry.yarnpkg.com/slate-react/-/slate-react-0.12.11.tgz#6d83e604634704757690a57dbd6aab282a964ad3"
-  dependencies:
-    debug "^3.1.0"
-    get-window "^1.1.1"
-    is-window "^1.0.2"
-    keycode "^2.1.2"
-    lodash "^4.1.1"
-    prop-types "^15.5.8"
-    react-immutable-proptypes "^2.1.0"
-    react-portal "^3.1.0"
-    selection-is-backward "^1.0.0"
-    slate-base64-serializer "^0.2.36"
-    slate-dev-environment "^0.1.2"
-    slate-dev-logger "^0.1.39"
-    slate-hotkeys "^0.1.2"
-    slate-plain-serializer "^0.5.17"
-    slate-prop-types "^0.4.34"
+slate-prop-types@^0.5.41:
+  version "0.5.41"
+  resolved "https://registry.yarnpkg.com/slate-prop-types/-/slate-prop-types-0.5.41.tgz#42031881e2fef4fa978a96b9aad84b093b4a5219"
+  integrity sha512-fLcXlugO9btF5b/by+dA+n8fn2mET75VGWltqFNxGdl6ncyBtrGspWA7mLVRFSqQWOS/Ig4A3URCRumOBBCUfQ==
 
-slate-schema-violations@^0.1.12:
-  version "0.1.39"
-  resolved "https://registry.yarnpkg.com/slate-schema-violations/-/slate-schema-violations-0.1.39.tgz#854ab5624136419cef4c803b1823acabe11f1c15"
+slate-react-placeholder@^0.2.8:
+  version "0.2.8"
+  resolved "https://registry.yarnpkg.com/slate-react-placeholder/-/slate-react-placeholder-0.2.8.tgz#973ac47c9a518a1418e89b6021b0f6120c07ce6f"
+  integrity sha512-CZZSg5usE2ZY/AYg06NVcL9Wia6hD/Mg0w4D4e9rPh6hkkFJg8LZXYMRz+6Q4v1dqHmzRsZ2Ixa0jRuiKXsMaQ==
 
-slate@0.33.8:
-  version "0.33.8"
-  resolved "https://registry.yarnpkg.com/slate/-/slate-0.33.8.tgz#c2cd9906c446d010b15e9e28f6d1a01792c7a113"
+slate@0.47.8:
+  version "0.47.8"
+  resolved "https://registry.yarnpkg.com/slate/-/slate-0.47.8.tgz#1e987b74d8216d44ec56154f0e6d3c722ce21e6e"
+  integrity sha512-/Jt0eq4P40qZvtzeKIvNb+1N97zVICulGQgQoMDH0TI8h8B+5kqa1YeckRdRnuvfYJm3J/9lWn2V3J1PrF+hag==
   dependencies:
     debug "^3.1.0"
     direction "^0.1.5"
     esrever "^0.2.0"
-    is-empty "^1.0.0"
     is-plain-object "^2.0.4"
     lodash "^4.17.4"
-    slate-dev-logger "^0.1.39"
-    slate-schema-violations "^0.1.12"
+    tiny-invariant "^1.0.1"
+    tiny-warning "^0.0.3"
     type-of "^2.0.1"
 
 slice-ansi@0.0.4:
@@ -17466,14 +17462,20 @@ tiny-emitter@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423"
 
-tiny-invariant@^1.0.2:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.4.tgz#346b5415fd93cb696b0c4e8a96697ff590f92463"
+tiny-invariant@^1.0.1, tiny-invariant@^1.0.2:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.6.tgz#b3f9b38835e36a41c843a3b0907a5a7b3755de73"
+  integrity sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA==
 
 tiny-relative-date@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/tiny-relative-date/-/tiny-relative-date-1.3.0.tgz#fa08aad501ed730f31cc043181d995c39a935e07"
 
+tiny-warning@^0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-0.0.3.tgz#1807eb4c5f81784a6354d58ea1d5024f18c6c81f"
+  integrity sha512-r0SSA5Y5IWERF9Xh++tFPx0jITBgGggOsRLDWWew6YRw/C2dr4uNO1fw1vanrBmHsICmPyMLNBZboTlxUmUuaA==
+
 tiny-warning@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.2.tgz#1dfae771ee1a04396bdfde27a3adcebc6b648b28"
@@ -17782,6 +17784,7 @@ type-name@^2.0.1:
 type-of@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/type-of/-/type-of-2.0.1.tgz#e72a1741896568e9f628378d816d6912f7f23972"
+  integrity sha1-5yoXQYllaOn2KDeNgW1pEvfyOXI=
 
 typed-styles@^0.0.7:
   version "0.0.7"
@@ -18628,7 +18631,6 @@ wrap-ansi@^5.1.0:
 wrappy@1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
-  integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
 
 write-file-atomic@2.4.1:
   version "2.4.1"