diff --git a/package.json b/package.json index a53ab845d1e..0f9a5d882f1 100644 --- a/package.json +++ b/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", diff --git a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts index 07b9f350cb8..34c663c4fe8 100644 --- a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts +++ b/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', diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 6fdc543fdb4..ac98227b8ed 100644 --- a/packages/grafana-ui/package.json +++ b/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", diff --git a/packages/grafana-ui/rollup.config.ts b/packages/grafana-ui/rollup.config.ts index 85564fa54e0..c79a2084fdd 100644 --- a/packages/grafana-ui/rollup.config.ts +++ b/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(), ], }; diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx index a9c15f4be7b..5b57c51aa75 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinkInput.tsx +++ b/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 = ({ 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 = ({ 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 = ({ value, onChange, s )} { + 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; +} diff --git a/packages/grafana-ui/src/slate-plugins/slate-prism/options.tsx b/packages/grafana-ui/src/slate-plugins/slate-prism/options.tsx new file mode 100644 index 00000000000..82320a5a132 --- /dev/null +++ b/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 {props.children}; +} + +/** + * 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; diff --git a/packages/grafana-ui/src/utils/slate.ts b/packages/grafana-ui/src/utils/slate.ts index e8a8dd71295..fcff5e43107 100644 --- a/packages/grafana-ui/src/utils/slate.ts +++ b/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); + }); }; diff --git a/packages/grafana-ui/tsconfig.json b/packages/grafana-ui/tsconfig.json index d6dbfc1e0b7..883bbe99ab1 100644 --- a/packages/grafana-ui/tsconfig.json +++ b/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" } diff --git a/public/app/features/explore/QueryField.test.tsx b/public/app/features/explore/QueryField.test.tsx index 274de4e7ceb..e09f00a7b9e 100644 --- a/public/app/features/explore/QueryField.test.tsx +++ b/public/app/features/explore/QueryField.test.tsx @@ -17,45 +17,4 @@ describe('', () => { const wrapper = shallow(); expect(wrapper.find('div').exists()).toBeTruthy(); }); - - it('should execute query when enter is pressed and there are no suggestions visible', () => { - const wrapper = shallow(); - 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(); - 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(); - 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(); - 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'); - }); }); diff --git a/public/app/features/explore/QueryField.tsx b/public/app/features/explore/QueryField.tsx index 8a61a4397e8..d2d58055c5f 100644 --- a/public/app/features/explore/QueryField.tsx +++ b/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'; + +import { makeValue, SCHEMA } from '@grafana/ui'; -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]; -} - -function hasSuggestions(suggestions: CompletionItemGroup[]): boolean { - return suggestions && suggestions.length > 0; -} 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; + 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 { 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) { 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 { + 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 { @@ -194,475 +168,18 @@ export class QueryField extends React.PureComponent { - 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); - }; + editor.blur(); - 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 ( - - - - ); - }; - - 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); - - return true; + return next(); }; render() { @@ -670,19 +187,20 @@ export class QueryField extends React.PureComponent
- {this.renderMenu()} (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 { - 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; diff --git a/public/app/features/explore/Typeahead.tsx b/public/app/features/explore/Typeahead.tsx index b28ab4a610d..91e675d7eb8 100644 --- a/public/app/features/explore/Typeahead.tsx +++ b/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 { - listRef: any = createRef(); - documentationRef: any = createRef(); + listRef = createRef(); constructor(props: Props) { super(props); @@ -35,97 +39,173 @@ export class Typeahead extends React.PureComponent { 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) => { - 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, prevState: Readonly) => { + 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; - } - - const index = this.state.allItems.findIndex(item => item === this.props.selectedItem); - const item = this.state.allItems[index]; - - if (item) { - this.documentationRef.current.refresh(item); - } - }; - - onMouseEnter = (item: CompletionItem) => { - this.documentationRef.current.refresh(item); + onMouseEnter = (index: number) => { + this.setState({ + hoveredItem: index, + }); }; onMouseLeave = () => { - this.documentationRef.current.hide(); + 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); + + if (this.state.allItems[newTypeaheadIndex].kind === CompletionItemKind.GroupTitle) { + newTypeaheadIndex = modulo(newTypeaheadIndex + moveAmount, itemCount); + } + + this.setState({ + typeaheadIndex: newTypeaheadIndex, + }); + + return; + } + }; + + insertSuggestion = () => { + this.props.onSelectSuggestion(this.state.allItems[this.state.typeaheadIndex]); + }; + + 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 ( -
    - - { - 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; - } + +
      + { + 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 ( - - ); - }} - -
    + return ( + this.props.onSelectSuggestion(item)} + isSelected={allItems[typeaheadIndex] === item} + item={item} + prefix={prefix} + style={style} + onMouseEnter={() => this.onMouseEnter(index)} + onMouseLeave={this.onMouseLeave} + /> + ); + }} +
    +
+ + {showDocumentation && ( + + )} + ); } } export const TypeaheadWithTheme = withTheme(Typeahead); + +interface PortalProps { + index?: number; + isOpen: boolean; + origin: string; +} + +class Portal extends React.PureComponent { + 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; + } +} diff --git a/public/app/features/explore/TypeaheadInfo.tsx b/public/app/features/explore/TypeaheadInfo.tsx index 4b410c8b365..f18edcee17d 100644 --- a/public/app/features/explore/TypeaheadInfo.tsx +++ b/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 { +export class TypeaheadInfo extends PureComponent { 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 { 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 { }; }; - 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 : ''; diff --git a/public/app/features/explore/TypeaheadItem.tsx b/public/app/features/explore/TypeaheadItem.tsx index f670330e44d..e20a5758613 100644 --- a/public/app/features/explore/TypeaheadItem.tsx +++ b/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) => { 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 (
  • {label} @@ -80,7 +76,13 @@ export const TypeaheadItem: FunctionComponent = (props: Props) => { } return ( -
  • +
  • ); diff --git a/public/app/features/explore/slate-plugins/braces.test.ts b/public/app/features/explore/slate-plugins/braces.test.ts deleted file mode 100644 index d72ea0f3d97..00000000000 --- a/public/app/features/explore/slate-plugins/braces.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/public/app/features/explore/slate-plugins/braces.test.tsx b/public/app/features/explore/slate-plugins/braces.test.tsx new file mode 100644 index 00000000000..a80f67c817f --- /dev/null +++ b/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(); + 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(); + 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(); + const event = new window.KeyboardEvent('keydown', { key: 'Backspace' }); + const handled = handler(event as Event, editor.instance().moveForward(5) as any, nextMock); + expect(handled).toBeFalsy(); + }); +}); diff --git a/public/app/features/explore/slate-plugins/braces.ts b/public/app/features/explore/slate-plugins/braces.ts index ee6227cc309..0eff1fa7e4f 100644 --- a/public/app/features/explore/slate-plugins/braces.ts +++ b/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(); }, }; } diff --git a/public/app/features/explore/slate-plugins/clear.test.ts b/public/app/features/explore/slate-plugins/clear.test.ts deleted file mode 100644 index 9322fffd7d2..00000000000 --- a/public/app/features/explore/slate-plugins/clear.test.ts +++ /dev/null @@ -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 '); - }); -}); diff --git a/public/app/features/explore/slate-plugins/clear.test.tsx b/public/app/features/explore/slate-plugins/clear.test.tsx new file mode 100644 index 00000000000..4565827e385 --- /dev/null +++ b/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(); + 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(); + 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(); + 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 '); + }); +}); diff --git a/public/app/features/explore/slate-plugins/clear.ts b/public/app/features/explore/slate-plugins/clear.ts index 9d649aa6926..83dcf2e27b7 100644 --- a/public/app/features/explore/slate-plugins/clear.ts +++ b/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(); }, }; } diff --git a/public/app/features/explore/slate-plugins/clipboard.ts b/public/app/features/explore/slate-plugins/clipboard.ts new file mode 100644 index 00000000000..79d277ec65a --- /dev/null +++ b/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; + }, + }; +} diff --git a/public/app/features/explore/slate-plugins/indentation.ts b/public/app/features/explore/slate-plugins/indentation.ts new file mode 100644 index 00000000000..d3f1ab154c3 --- /dev/null +++ b/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; + }, + }; +} diff --git a/public/app/features/explore/slate-plugins/newline.ts b/public/app/features/explore/slate-plugins/newline.ts index a20bb162870..c31d2a74b18 100644 --- a/public/app/features/explore/slate-plugins/newline.ts +++ b/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(); }, }; } diff --git a/public/app/features/explore/slate-plugins/runner.test.tsx b/public/app/features/explore/slate-plugins/runner.test.tsx new file mode 100644 index 00000000000..3604681e03a --- /dev/null +++ b/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(); + handler({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent, editor.instance() as any, () => {}); + expect(mockHandler).toBeCalled(); + }); +}); diff --git a/public/app/features/explore/slate-plugins/runner.ts b/public/app/features/explore/slate-plugins/runner.ts index fc7b8a778ed..bb3a10f8759 100644 --- a/public/app/features/explore/slate-plugins/runner.ts +++ b/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(); }, }; } diff --git a/public/app/features/explore/slate-plugins/selection_shortcuts.ts b/public/app/features/explore/slate-plugins/selection_shortcuts.ts new file mode 100644 index 00000000000..d0849d34f04 --- /dev/null +++ b/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; + }, + }; +} diff --git a/public/app/features/explore/slate-plugins/suggestions.tsx b/public/app/features/explore/slate-plugins/suggestions.tsx new file mode 100644 index 00000000000..a3106ff5795 --- /dev/null +++ b/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; + 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} + (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, + 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 +); diff --git a/public/app/features/explore/utils/typeahead.ts b/public/app/features/explore/utils/typeahead.ts index 7de817e4578..e501e2ab60a 100644 --- a/public/app/features/explore/utils/typeahead.ts +++ b/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; diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index 1425eeb3226..2b7af6e3225 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/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); diff --git a/public/app/plugins/datasource/elasticsearch/components/ElasticsearchQueryField.tsx b/public/app/plugins/datasource/elasticsearch/components/ElasticsearchQueryField.tsx index ab6ef40272c..98c01ba1057 100644 --- a/public/app/plugins/datasource/elasticsearch/components/ElasticsearchQueryField.tsx +++ b/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 { super(props, context); this.plugins = [ - PluginPrism({ + SlatePrism({ onlyIn: (node: any) => node.type === 'code_block', getSyntax: (node: any) => 'lucene', }), diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx index d3052691acc..9e8c4a3205c 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/KustoQueryField.tsx +++ b/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 [ diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/query_field.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/query_field.tsx index 42b2f1e858d..8f81da9a94a 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/editor/query_field.tsx +++ b/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 { 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 { } }; - 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 { } 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 { 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 { 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 { 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 { 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 { 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); }; diff --git a/public/app/plugins/datasource/loki/components/LokiQueryField.tsx b/public/app/plugins/datasource/loki/components/LokiQueryField.tsx index de7f31ac7f8..e90aff765be 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryField.tsx +++ b/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 = ({ datasource, @@ -8,7 +9,7 @@ export const LokiQueryField: FunctionComponent = ({ ...otherProps }) => { const { isSyntaxReady, setActiveOption, refreshLabels, ...syntaxProps } = useLokiSyntax( - datasource.languageProvider, + datasource.languageProvider as LokiLanguageProvider, datasourceStatus, otherProps.absoluteRange ); diff --git a/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx b/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx index 57b0b6987f0..f343bb5eab5 100644 --- a/public/app/plugins/datasource/loki/components/LokiQueryFieldForm.tsx +++ b/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, 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 { - plugins: any[]; + plugins: Plugin[]; modifiedSearch: string; modifiedQuery: string; @@ -82,9 +88,9 @@ export class LokiQueryFieldForm extends React.PureComponent 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 { + onTypeahead = async (typeahead: TypeaheadInput): Promise => { 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; + const { prefix, text, value, wrapperClasses, labelKey } = 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 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 0; const chooserText = getChooserText(syntaxLoaded, hasLogLabels, datasourceStatus); const buttonDisabled = !syntaxLoaded || datasourceStatus === DataSourceStatus.Disconnected; diff --git a/public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts b/public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts index 62de5c156ad..07c98cc476c 100644 --- a/public/app/plugins/datasource/loki/components/useLokiSyntax.test.ts +++ b/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'; diff --git a/public/app/plugins/datasource/loki/components/useLokiSyntax.ts b/public/app/plugins/datasource/loki/components/useLokiSyntax.ts index 7faa5a6fb24..f4ae3652e4d 100644 --- a/public/app/plugins/datasource/loki/components/useLokiSyntax.ts +++ b/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'; diff --git a/public/app/plugins/datasource/loki/language_provider.test.ts b/public/app/plugins/datasource/loki/language_provider.test.ts index 4f0ac9324aa..0b8caa16f0a 100644 --- a/public/app/plugins/datasource/loki/language_provider.test.ts +++ b/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: '', diff --git a/public/app/plugins/datasource/loki/language_provider.ts b/public/app/plugins/datasource/loki/language_provider.ts index 44ba031255f..ac5f877f8d4 100644 --- a/public/app/plugins/datasource/loki/language_provider.ts +++ b/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 { 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 { let context: string; - let refresher: Promise = 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 { diff --git a/public/app/plugins/datasource/loki/mocks.ts b/public/app/plugins/datasource/loki/mocks.ts index 49c2de7dcc0..7e91c51c105 100644 --- a/public/app/plugins/datasource/loki/mocks.ts +++ b/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) => { diff --git a/public/app/plugins/datasource/loki/syntax.ts b/public/app/plugins/datasource/loki/syntax.ts index 0748b4e5ffd..2e83723a815 100644 --- a/public/app/plugins/datasource/loki/syntax.ts +++ b/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, diff --git a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx b/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx index 99eef38bde3..963a2d79a30 100644 --- a/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx +++ b/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 { - history: HistoryItem[]; + history: Array>; } interface PromQueryFieldState { @@ -113,7 +115,7 @@ interface PromQueryFieldState { class PromQueryField extends React.PureComponent { plugins: any[]; - languageProvider: any; + languageProvider: PromQlLanguageProvider; languageProviderInitializationPromise: CancelablePromise; constructor(props: PromQueryFieldProps, context: React.Context) { @@ -125,7 +127,7 @@ class PromQueryField extends React.PureComponent node.type === 'code_block', getSyntax: (node: any) => 'promql', }), @@ -252,7 +254,7 @@ class PromQueryField extends React.PureComponent { + onTypeahead = async (typeahead: TypeaheadInput): Promise => { if (!this.languageProvider) { return { suggestions: [] }; } const { history } = this.props; - const { prefix, text, value, wrapperNode } = typeahead; + const { prefix, text, value, wrapperClasses, labelKey } = 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 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; }; diff --git a/public/app/plugins/datasource/prometheus/language_provider.ts b/public/app/plugins/datasource/prometheus/language_provider.ts index 86cdb57f02a..018b32e4981 100644 --- a/public/app/plugins/datasource/prometheus/language_provider.ts +++ b/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; + 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> } = { history: [] } + ): Promise => { // 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> }): 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 = 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 = null; - const suggestions: CompletionItemGroup[] = []; + getLabelCompletionItems = async ({ + text, + wrapperClasses, + labelKey, + value, + }: TypeaheadInput): Promise => { 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 { diff --git a/public/app/plugins/datasource/prometheus/language_utils.ts b/public/app/plugins/datasource/prometheus/language_utils.ts index f10d9eed422..535f86fc8bc 100644 --- a/public/app/plugins/datasource/prometheus/language_utils.ts +++ b/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]); } }); diff --git a/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts b/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts index fe1679eb94b..9171201fc37 100644 --- a/public/app/plugins/datasource/prometheus/specs/language_provider.test.ts +++ b/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> = [ { + 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'] }, + 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 value = Plain.deserialize('{job1="foo",job2!="foo",job3=~"foo",}'); - const range = value.selection.merge({ - anchorOffset: 36, - }); - 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'], diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 09114c24738..3b06385efe8 100644 --- a/public/app/types/explore.ts +++ b/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 { } export abstract class LanguageProvider { - datasource: any; + datasource: DataSourceApi; request: (url: string, params?: any) => Promise; /** * 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[]; } diff --git a/public/sass/components/_slate_editor.scss b/public/sass/components/_slate_editor.scss index 50a58f8ff0c..fe0d9c10f05 100644 --- a/public/sass/components/_slate_editor.scss +++ b/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; } diff --git a/tsconfig.json b/tsconfig.json index cffbb331969..da5818b01de 100644 --- a/tsconfig.json +++ b/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 diff --git a/yarn.lock b/yarn.lock index 3493e5c28db..1f6b4fe0335 100644 --- a/yarn.lock +++ b/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.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== +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._reinterpolate "~3.0.0" + lodash.templatesettings "^4.0.0" + +lodash.templatesettings@^4.0.0: + 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.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: +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.5.41: 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" + resolved "https://registry.yarnpkg.com/slate-prop-types/-/slate-prop-types-0.5.41.tgz#42031881e2fef4fa978a96b9aad84b093b4a5219" + integrity sha512-fLcXlugO9btF5b/by+dA+n8fn2mET75VGWltqFNxGdl6ncyBtrGspWA7mLVRFSqQWOS/Ig4A3URCRumOBBCUfQ== -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-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-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-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@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"