Revert "Chore: Update Slate to 0.47.8 (#18412)" (#19167)

This reverts commit 601853fc84.
This commit is contained in:
Dominik Prokop 2019-09-17 13:21:50 +02:00 committed by David
parent 601853fc84
commit 503dccb771
51 changed files with 1320 additions and 1707 deletions

View File

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

View File

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

View File

@ -26,12 +26,10 @@
},
"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",
@ -47,7 +45,6 @@
"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": {
@ -68,8 +65,6 @@
"@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",

View File

@ -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,20 +47,19 @@ 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(),
],
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,24 +1,21 @@
import React, { createRef, CSSProperties } from 'react';
import ReactDOM from 'react-dom';
import React, { createRef } from 'react';
import _ from 'lodash';
import { FixedSizeList } from 'react-window';
import { Themeable, withTheme } from '@grafana/ui';
import { CompletionItem, CompletionItemKind, CompletionItemGroup } from 'app/types/explore';
import { CompletionItem, 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;
menuRef?: (el: Typeahead) => void;
onSelectSuggestion?: (suggestion: CompletionItem) => void;
isOpen?: boolean;
typeaheadIndex: number;
}
interface State {
@ -26,12 +23,11 @@ interface State {
listWidth: number;
listHeight: number;
itemHeight: number;
hoveredItem: number;
typeaheadIndex: number;
}
export class Typeahead extends React.PureComponent<Props, State> {
listRef = createRef<FixedSizeList>();
listRef: any = createRef();
documentationRef: any = createRef();
constructor(props: Props) {
super(props);
@ -39,173 +35,97 @@ export class Typeahead extends React.PureComponent<Props, State> {
const allItems = flattenGroupItems(props.groupedItems);
const longestLabel = calculateLongestLabel(allItems);
const { listWidth, listHeight, itemHeight } = calculateListSizes(props.theme, allItems, longestLabel);
this.state = { listWidth, listHeight, itemHeight, hoveredItem: null, typeaheadIndex: 1, allItems };
this.state = { listWidth, listHeight, itemHeight, allItems };
}
componentDidMount = () => {
this.props.menuRef(this);
};
componentDidUpdate = (prevProps: Readonly<Props>, prevState: Readonly<State>) => {
if (prevState.typeaheadIndex !== this.state.typeaheadIndex && this.listRef && this.listRef.current) {
if (this.state.typeaheadIndex === 1) {
componentDidUpdate = (prevProps: Readonly<Props>) => {
if (prevProps.typeaheadIndex !== this.props.typeaheadIndex && this.listRef && this.listRef.current) {
if (prevProps.typeaheadIndex === 1 && this.props.typeaheadIndex === 0) {
this.listRef.current.scrollToItem(0); // special case for handling the first group label
this.refreshDocumentation();
return;
}
this.listRef.current.scrollToItem(this.state.typeaheadIndex);
const index = this.state.allItems.findIndex(item => item === this.props.selectedItem);
this.listRef.current.scrollToItem(index);
this.refreshDocumentation();
}
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.setState({ listWidth, listHeight, itemHeight, allItems }, () => this.refreshDocumentation());
}
};
onMouseEnter = (index: number) => {
this.setState({
hoveredItem: index,
});
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);
};
onMouseLeave = () => {
this.setState({
hoveredItem: null,
});
this.documentationRef.current.hide();
};
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 { prefix, theme, isOpen, origin } = this.props;
const { allItems, listWidth, listHeight, itemHeight, hoveredItem, typeaheadIndex } = this.state;
const showDocumentation = hoveredItem || typeaheadIndex;
const { menuRef, selectedItem, onClickItem, prefix, theme } = this.props;
const { listWidth, listHeight, itemHeight, allItems } = this.state;
return (
<Portal origin={origin} isOpen={isOpen}>
<ul className="typeahead" style={this.menuPosition}>
<FixedSizeList
ref={this.listRef}
itemCount={allItems.length}
itemSize={itemHeight}
itemKey={index => {
const item = allItems && allItems[index];
const key = item ? `${index}-${item.label}` : `${index}`;
return key;
}}
width={listWidth}
height={listHeight}
>
{({ index, style }) => {
const item = allItems && allItems[index];
if (!item) {
return null;
}
<ul className="typeahead" ref={menuRef}>
<TypeaheadInfo
ref={this.documentationRef}
width={listWidth}
height={listHeight}
theme={theme}
initialItem={selectedItem}
/>
<FixedSizeList
ref={this.listRef}
itemCount={allItems.length}
itemSize={itemHeight}
itemKey={index => {
const item = allItems && allItems[index];
const key = item ? `${index}-${item.label}` : `${index}`;
return key;
}}
width={listWidth}
height={listHeight}
>
{({ index, style }) => {
const item = allItems && allItems[index];
if (!item) {
return null;
}
return (
<TypeaheadItem
onClickItem={() => this.props.onSelectSuggestion(item)}
isSelected={allItems[typeaheadIndex] === item}
item={item}
prefix={prefix}
style={style}
onMouseEnter={() => this.onMouseEnter(index)}
onMouseLeave={this.onMouseLeave}
/>
);
}}
</FixedSizeList>
</ul>
{showDocumentation && (
<TypeaheadInfo
width={listWidth}
height={listHeight}
theme={theme}
item={allItems[hoveredItem ? hoveredItem : typeaheadIndex]}
/>
)}
</Portal>
return (
<TypeaheadItem
onClickItem={onClickItem}
isSelected={selectedItem === item}
item={item}
prefix={prefix}
style={style}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
/>
);
}}
</FixedSizeList>
</ul>
);
}
}
export const TypeaheadWithTheme = withTheme(Typeahead);
interface PortalProps {
index?: number;
isOpen: boolean;
origin: string;
}
class Portal extends React.PureComponent<PortalProps, {}> {
node: HTMLElement;
constructor(props: PortalProps) {
super(props);
const { index = 0, origin = 'query' } = props;
this.node = document.createElement('div');
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${origin}-${index}`);
document.body.appendChild(this.node);
}
componentWillUnmount() {
document.body.removeChild(this.node);
}
render() {
if (this.props.isOpen) {
return ReactDOM.createPortal(this.props.children, this.node);
}
return null;
}
}

View File

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

View File

@ -1,21 +1,25 @@
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, CompletionItemKind } from 'app/types/explore';
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;
};
interface Props {
isSelected: boolean;
item: CompletionItem;
style: any;
onClickItem: (suggestion: CompletionItem) => void;
prefix?: string;
onClickItem?: (event: React.MouseEvent) => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
style: any;
onMouseEnter: (item: CompletionItem) => void;
onMouseLeave: (item: CompletionItem) => void;
}
const getStyles = (theme: GrafanaTheme) => ({
@ -34,12 +38,10 @@ 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};
@ -47,7 +49,6 @@ const getStyles = (theme: GrafanaTheme) => ({
padding: inherit;
background: inherit;
`,
typeaheadItemGroupTitle: css`
label: type-ahead-item-group-title;
color: ${theme.colors.textWeak};
@ -61,13 +62,16 @@ export const TypeaheadItem: FunctionComponent<Props> = (props: Props) => {
const theme = useContext(ThemeContext);
const styles = getStyles(theme);
const { isSelected, item, prefix, style, onMouseEnter, onMouseLeave, onClickItem } = props;
const { isSelected, item, prefix, style, onClickItem } = props;
const onClick = () => onClickItem(item);
const onMouseEnter = () => props.onMouseEnter(item);
const onMouseLeave = () => props.onMouseLeave(item);
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 (item.kind === CompletionItemKind.GroupTitle) {
if (isGroupTitle(item)) {
return (
<li className={itemGroupTitleClassName} style={style}>
<span>{label}</span>
@ -76,13 +80,7 @@ export const TypeaheadItem: FunctionComponent<Props> = (props: Props) => {
}
return (
<li
className={className}
style={style}
onMouseDown={onClickItem}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<li className={className} onClick={onClick} style={style} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<Highlighter textToHighlight={label} searchWords={[prefix]} highlightClassName={highlightClassName} />
</li>
);

View File

@ -0,0 +1,39 @@
// @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();
});
});

View File

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

View File

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

View File

@ -0,0 +1,39 @@
// @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 ');
});
});

View File

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

View File

@ -1,27 +1,22 @@
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(): Plugin {
export default function ClearPlugin() {
return {
onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) {
const value = editor.value;
if (value.selection.isExpanded) {
return next();
onKeyDown(event: any, change: { value?: any; deleteForward?: any }) {
const { value } = change;
if (!value.isCollapsed) {
return undefined;
}
if (event.key === 'k' && event.ctrlKey) {
event.preventDefault();
const text = value.anchorText.text;
const offset = value.selection.anchor.offset;
const offset = value.anchorOffset;
const length = text.length;
const forward = length - offset;
editor.deleteForward(forward);
change.deleteForward(forward);
return true;
}
return next();
return undefined;
},
};
}

View File

@ -1,61 +0,0 @@
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;
},
};
}

View File

@ -1,93 +0,0 @@
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;
},
};
}

View File

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

View File

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

View File

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

View File

@ -1,72 +0,0 @@
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;
},
};
}

View File

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

View File

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

View File

@ -10,7 +10,7 @@ import jquery from 'jquery';
import prismjs from 'prismjs';
import slate from 'slate';
// @ts-ignore
import slateReact from '@grafana/slate-react';
import slateReact from '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('@grafana/slate-react', slateReact);
exposeToPlugin('slate-react', slateReact);
exposeToPlugin('slate-plain-serializer', slatePlain);
exposeToPlugin('react', react);
exposeToPlugin('react-dom', reactDom);

View File

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

View File

@ -1,13 +1,12 @@
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;
@ -64,7 +63,7 @@ export default class KustoQueryField extends QueryField {
this.fetchSchema();
}
onTypeahead = (force = false) => {
onTypeahead = (force?: boolean) => {
const selection = window.getSelection();
if (selection.anchorNode) {
const wrapperNode = selection.anchorNode.parentElement;
@ -197,15 +196,15 @@ export default class KustoQueryField extends QueryField {
}
};
applyTypeahead = (editor: SlateEditor, suggestion: CompletionItem): SlateEditor => {
applyTypeahead(change: any, suggestion: { text: any; type: string; deleteBackwards: any }) {
const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state;
let suggestionText = suggestion.label;
let suggestionText = suggestion.text || suggestion;
const move = 0;
// Modify suggestion based on context
const nextChar = DOMUtil.getNextCharacter();
if (suggestion.kind === 'function') {
if (suggestion.type === 'function') {
if (!nextChar || nextChar !== '(') {
suggestionText += '(';
}
@ -229,13 +228,13 @@ export default class KustoQueryField extends QueryField {
const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
const forward = midWord ? suffixLength + offset : 0;
return editor
return change
.deleteBackward(backward)
.deleteForward(forward)
.insertText(suggestionText)
.moveForward(move)
.move(move)
.focus();
};
}
// private _getFieldsSuggestions(): SuggestionGroup[] {
// return [

View File

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

View File

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

View File

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

View File

@ -3,7 +3,6 @@ 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';

View File

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

View File

@ -1,14 +1,13 @@
// @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({});
@ -19,16 +18,16 @@ describe('Language completion provider', () => {
};
describe('empty query suggestions', () => {
it('returns no suggestions on empty context', async () => {
it('returns no suggestions on empty context', () => {
const instance = new LanguageProvider(datasource);
const value = Plain.deserialize('');
const result = await instance.provideCompletionItems({ text: '', prefix: '', value, wrapperClasses: [] });
const result = 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', async () => {
it('returns default suggestions with history on empty context when history was provided', () => {
const instance = new LanguageProvider(datasource);
const value = Plain.deserialize('');
const history: LokiHistoryItem[] = [
@ -37,12 +36,12 @@ describe('Language completion provider', () => {
ts: 1,
},
];
const result = await instance.provideCompletionItems(
const result = instance.provideCompletionItems(
{ text: '', prefix: '', value, wrapperClasses: [] },
{ history, absoluteRange: rangeMock }
);
expect(result.context).toBeUndefined();
expect(result.refresher).toBeUndefined();
expect(result.suggestions).toMatchObject([
{
label: 'History',
@ -55,7 +54,7 @@ describe('Language completion provider', () => {
]);
});
it('returns no suggestions within regexp', async () => {
it('returns no suggestions within regexp', () => {
const instance = new LanguageProvider(datasource);
const input = createTypeaheadInput('{} ()', '', undefined, 4, []);
const history: LokiHistoryItem[] = [
@ -64,28 +63,18 @@ describe('Language completion provider', () => {
ts: 1,
},
];
const result = await instance.provideCompletionItems(input, { history });
const result = 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', async () => {
it('returns default label suggestions on label context', () => {
const instance = new LanguageProvider(datasource);
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 }
);
const input = createTypeaheadInput('{}', '');
const result = instance.provideCompletionItems(input, { absoluteRange: rangeMock });
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'job' }, { label: 'namespace' }], label: 'Labels' }]);
});
@ -94,7 +83,7 @@ describe('Language completion provider', () => {
const datasource = makeMockLokiDatasource({ label1: [], label2: [] });
const provider = await getLanguageProvider(datasource);
const input = createTypeaheadInput('{}', '');
const result = await provider.provideCompletionItems(input, { absoluteRange: rangeMock });
const result = provider.provideCompletionItems(input, { absoluteRange: rangeMock });
expect(result.context).toBe('context-labels');
expect(result.suggestions).toEqual([{ items: [{ label: 'label1' }, { label: 'label2' }], label: 'Labels' }]);
});
@ -103,9 +92,11 @@ 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 = await provider.provideCompletionItems(input, { absoluteRange: rangeMock });
result = await provider.provideCompletionItems(input, { absoluteRange: rangeMock });
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 });
expect(result.context).toBe('context-label-values');
expect(result.suggestions).toEqual([
{ items: [{ label: 'label1_val1' }, { label: 'label1_val2' }], label: 'Label values for "label1"' },
@ -210,7 +201,7 @@ describe('Labels refresh', () => {
});
});
async function getLanguageProvider(datasource: LokiDatasource) {
async function getLanguageProvider(datasource: DataSourceApi) {
const instance = new LanguageProvider(datasource);
instance.initialRange = {
from: Date.now() - 10000,
@ -233,8 +224,10 @@ function createTypeaheadInput(
wrapperClasses?: string[]
): TypeaheadInput {
const deserialized = Plain.deserialize(value);
const range = deserialized.selection.setAnchor(deserialized.selection.anchor.setOffset(anchorOffset || 1));
const valueWithSelection = deserialized.setSelection(range);
const range = deserialized.selection.merge({
anchorOffset: anchorOffset || 1,
});
const valueWithSelection = deserialized.change().select(range).value;
return {
text,
prefix: '',

View File

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

View File

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

View File

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

View File

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

View File

@ -1,28 +1,23 @@
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 { PrometheusDatasource } from './datasource';
import { PromQuery } from './types';
import { dateTime } from '@grafana/data';
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): CompletionItem => ({ label });
const wrapLabel = (label: string) => ({ label });
const setFunctionKind = (suggestion: CompletionItem): CompletionItem => {
suggestion.kind = 'function';
@ -35,12 +30,10 @@ 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,
@ -54,9 +47,8 @@ export default class PromQlLanguageProvider extends LanguageProvider {
labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...]
metrics?: string[];
startTask: Promise<any>;
datasource: PrometheusDatasource;
constructor(datasource: PrometheusDatasource, initialValues?: any) {
constructor(datasource: any, initialValues?: any) {
super();
this.datasource = datasource;
@ -68,11 +60,10 @@ export default class PromQlLanguageProvider extends LanguageProvider {
Object.assign(this, initialValues);
}
// Strip syntax chars
cleanText = (s: string) => s.replace(/[{}[\]="(),!~+\-*/^%]/g, '').trim();
get syntax() {
getSyntax() {
return PromqlSyntax;
}
@ -115,46 +106,39 @@ export default class PromQlLanguageProvider extends LanguageProvider {
}
};
provideCompletionItems = async (
{ prefix, text, value, labelKey, wrapperClasses }: TypeaheadInput,
context: { history: Array<HistoryItem<PromQuery>> } = { history: [] }
): Promise<TypeaheadOutput> => {
// Keep this DOM-free for testing
provideCompletionItems({ prefix, wrapperClasses, text, value }: TypeaheadInput, context?: any): TypeaheadOutput {
// Local text properties
const empty = value.document.text.length === 0;
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;
const selectedLines = value.document.getTextsAtRangeAsArray(value.selection);
const currentLine = selectedLines.length === 1 ? selectedLines[0] : null;
const nextCharacter = currentLine ? currentLine.text[value.selection.anchorOffset] : 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 follow a complete expression and has no text after it
// Empty prefix is safe if it does not immediately folllow 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 operatorsPattern = /[+\-*/^%]/;
const isNextOperand = text.match(operatorsPattern);
const isNextOperand = text.match(/[+\-*/^%]/);
// Determine candidates by CSS context
if (wrapperClasses.includes('context-range')) {
if (_.includes(wrapperClasses, 'context-range')) {
// Suggestions for metric[|]
return this.getRangeCompletionItems();
} else if (wrapperClasses.includes('context-labels')) {
} else if (_.includes(wrapperClasses, 'context-labels')) {
// Suggestions for metric{|} and metric{foo=|}, as well as metric-independent label queries like {|}
return this.getLabelCompletionItems({ prefix, text, value, labelKey, wrapperClasses });
} else if (wrapperClasses.includes('context-aggregation')) {
return this.getLabelCompletionItems.apply(this, arguments);
} else if (_.includes(wrapperClasses, 'context-aggregation')) {
// Suggestions for sum(metric) by (|)
return this.getAggregationCompletionItems({ prefix, text, value, labelKey, wrapperClasses });
return this.getAggregationCompletionItems.apply(this, arguments);
} else if (empty) {
// Suggestions for empty query field
return this.getEmptyCompletionItems(context);
} else if ((prefixUnrecognized && noSuffix) || safeEmptyPrefix || isNextOperand) {
return this.getEmptyCompletionItems(context || {});
} else if (prefixUnrecognized || safeEmptyPrefix || isNextOperand) {
// Show term suggestions in a couple of scenarios
return this.getTermCompletionItems();
}
@ -162,20 +146,20 @@ export default class PromQlLanguageProvider extends LanguageProvider {
return {
suggestions: [],
};
};
}
getEmptyCompletionItems = (context: { history: Array<HistoryItem<PromQuery>> }): TypeaheadOutput => {
getEmptyCompletionItems(context: any): TypeaheadOutput {
const { history } = context;
const suggestions = [];
let suggestions: CompletionItemGroup[] = [];
if (history && history.length) {
if (history && history.length > 0) {
const historyItems = _.chain(history)
.map(h => h.query.expr)
.map((h: any) => h.query.expr)
.filter()
.uniq()
.take(HISTORY_ITEM_COUNT)
.map(wrapLabel)
.map(item => addHistoryMetadata(item, history))
.map((item: CompletionItem) => addHistoryMetadata(item, history))
.value();
suggestions.push({
@ -187,14 +171,14 @@ export default class PromQlLanguageProvider extends LanguageProvider {
}
const termCompletionItems = this.getTermCompletionItems();
suggestions.push(...termCompletionItems.suggestions);
suggestions = [...suggestions, ...termCompletionItems.suggestions];
return { suggestions };
};
}
getTermCompletionItems = (): TypeaheadOutput => {
getTermCompletionItems(): TypeaheadOutput {
const { metrics } = this;
const suggestions = [];
const suggestions: CompletionItemGroup[] = [];
suggestions.push({
prefixMatch: true,
@ -202,15 +186,14 @@ export default class PromQlLanguageProvider extends LanguageProvider {
items: FUNCTIONS.map(setFunctionKind),
});
if (metrics && metrics.length) {
if (metrics && metrics.length > 0) {
suggestions.push({
label: 'Metrics',
items: metrics.map(wrapLabel),
});
}
return { suggestions };
};
}
getRangeCompletionItems(): TypeaheadOutput {
return {
@ -236,21 +219,21 @@ export default class PromQlLanguageProvider extends LanguageProvider {
);
}
getAggregationCompletionItems = ({ value }: TypeaheadInput): TypeaheadOutput => {
getAggregationCompletionItems({ value }: TypeaheadInput): TypeaheadOutput {
const refresher: Promise<any> = null;
const suggestions: CompletionItemGroup[] = [];
// Stitch all query lines together to support multi-line queries
let queryOffset;
const queryText = value.document.getBlocks().reduce((text: string, block) => {
const queryText = value.document.getBlocks().reduce((text: string, block: any) => {
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.selection.anchor.offset + text.length;
queryOffset = value.anchorOffset + text.length;
}
return text + blockText;
text += blockText;
return text;
}, '');
// Try search for selector part on the left-hand side, such as `sum (m) by (l)`
@ -276,10 +259,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
const selectorString = queryText
.slice(openParensSelectorIndex + 1, closeParensSelectorIndex)
.replace(/\[[^\]]+\]$/, '');
selectorString = selectorString.replace(/\[[^\]]+\]$/, '');
const selector = parseSelector(selectorString, selectorString.length - 2).selector;
@ -291,16 +274,14 @@ export default class PromQlLanguageProvider extends LanguageProvider {
}
return result;
};
}
getLabelCompletionItems = async ({
text,
wrapperClasses,
labelKey,
value,
}: TypeaheadInput): Promise<TypeaheadOutput> => {
getLabelCompletionItems({ text, wrapperClasses, labelKey, value }: TypeaheadInput): TypeaheadOutput {
let context: string;
let refresher: Promise<any> = null;
const suggestions: CompletionItemGroup[] = [];
const line = value.anchorBlock.getText();
const cursorOffset = value.selection.anchor.offset;
const cursorOffset: number = value.anchorOffset;
// Get normalized selector
let selector;
@ -311,23 +292,10 @@ export default class PromQlLanguageProvider extends LanguageProvider {
} catch {
selector = EMPTY_SELECTOR;
}
const containsMetric = selector.includes('__name__=');
const containsMetric = selector.indexOf('__name__=') > -1;
const existingKeys = parsedSelector ? parsedSelector.labelKeys : [];
// 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')) {
if ((text && text.match(/^!?=~?/)) || _.includes(wrapperClasses, 'attr-value')) {
// Label values
if (labelKey && this.labelValues[selector] && this.labelValues[selector][labelKey]) {
const labelValues = this.labelValues[selector][labelKey];
@ -340,20 +308,27 @@ 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) {
if (possibleKeys.length > 0) {
context = 'context-labels';
const newItems = possibleKeys.map(key => ({ label: key }));
const newSuggestion: CompletionItemGroup = { label: `Labels`, items: newItems };
suggestions.push(newSuggestion);
suggestions.push({ label: `Labels`, items: possibleKeys.map(wrapLabel) });
}
}
}
return { context, suggestions };
};
// 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 };
}
fetchLabelValues = async (key: string) => {
try {

View File

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

View File

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

View File

@ -23,18 +23,11 @@ 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
@ -42,48 +35,40 @@ export interface CompletionItem {
* this completion.
*/
label: string;
/**
* The kind of this completion item. An icon is chosen
* by the editor based on the kind.
* The kind of this completion item. Based on the kind
* an icon is chosen by the editor.
*/
kind?: CompletionItemKind | string;
kind?: 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.
*/
@ -95,22 +80,18 @@ 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.
*/
@ -313,7 +294,7 @@ export interface HistoryItem<TQuery extends DataQuery = DataQuery> {
}
export abstract class LanguageProvider {
datasource: DataSourceApi;
datasource: any;
request: (url: string, params?: any) => Promise<any>;
/**
* Returns startTask that resolves with a task list when main syntax is loaded.
@ -328,12 +309,13 @@ export interface TypeaheadInput {
prefix: string;
wrapperClasses: string[];
labelKey?: string;
value?: Value;
editor?: Editor;
//Should be Value from slate
value?: any;
}
export interface TypeaheadOutput {
context?: string;
refresher?: Promise<{}>;
suggestions: CompletionItemGroup[];
}

View File

@ -30,9 +30,9 @@
.typeahead {
position: absolute;
z-index: auto;
top: 100px;
left: 160px;
//opacity: 0;
top: -10000px;
left: -10000px;
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;
}

View File

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

220
yarn.lock
View File

@ -1227,28 +1227,6 @@
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"
@ -3430,26 +3408,10 @@
version "7.0.11"
resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-7.0.11.tgz#6f28f005a36e779b7db0f1359b9fb9eef72aae88"
"@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==
"@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==
dependencies:
"@types/react" "*"
immutable "^3.8.2"
@ -4713,6 +4675,7 @@ 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"
@ -4871,6 +4834,7 @@ 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"
@ -5759,6 +5723,7 @@ 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"
@ -7189,7 +7154,6 @@ 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"
@ -7781,7 +7745,6 @@ 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"
@ -8325,6 +8288,7 @@ 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"
@ -8485,6 +8449,7 @@ 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"
@ -8937,8 +8902,9 @@ 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.1.15"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00"
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==
"graceful-readlink@>= 1.0.0":
version "1.0.1"
@ -9668,6 +9634,7 @@ 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"
@ -9976,6 +9943,10 @@ 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"
@ -9989,6 +9960,7 @@ 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"
@ -10050,7 +10022,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.4, is-hotkey@^0.1.1:
version "0.1.4"
resolved "https://registry.yarnpkg.com/is-hotkey/-/is-hotkey-0.1.4.tgz#c34d2c85d6ec8d09a871dcf71931c8067a824c7d"
@ -10305,6 +10277,7 @@ 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"
@ -10941,7 +10914,7 @@ kew@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/kew/-/kew-0.7.0.tgz#79d93d2d33363d6fdd2970b335d9141ad591d79b"
keycode@^2.2.0:
keycode@^2.1.2, keycode@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/keycode/-/keycode-2.2.0.tgz#3d0af56dc7b8b8e5cba8d0a97f107204eec22b04"
@ -11364,8 +11337,9 @@ lockfile@^1.0.4:
signal-exit "^3.0.2"
lodash-es@^4.17.11, lodash-es@^4.2.1:
version "4.17.11"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.11.tgz#145ab4a7ac5c5e52a3531fb4f310255a152b4be0"
version "4.17.15"
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78"
integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ==
lodash._baseuniq@~4.6.0:
version "4.6.0"
@ -11382,7 +11356,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=
@ -11453,8 +11427,9 @@ 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.1"
resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz#639057e726c3afbdb3e7d42741caa8d6e4335927"
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55"
integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==
lodash.once@^4.1.1:
version "4.1.1"
@ -11477,7 +11452,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.0.2, lodash.template@^4.2.4:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab"
integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==
@ -11485,20 +11460,12 @@ lodash.template@^4.0.2:
lodash._reinterpolate "^3.0.0"
lodash.templatesettings "^4.0.0"
lodash.template@^4.2.4:
version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.4.0.tgz#e73a0385c8355591746e020b99679c690e68fba0"
integrity sha1-5zoDhcg1VZF0bgILmWecaQ5o+6A=
dependencies:
lodash._reinterpolate "~3.0.0"
lodash.templatesettings "^4.0.0"
lodash.templatesettings@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.1.0.tgz#2b4d4e95ba440d915ff08bc899e4553666713316"
integrity sha1-K01OlbpEDZFf8IvImeRVNmZxMxY=
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==
dependencies:
lodash._reinterpolate "~3.0.0"
lodash._reinterpolate "^3.0.0"
lodash.throttle@^4.1.1:
version "4.1.1"
@ -12001,6 +11968,7 @@ 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"
@ -12021,6 +11989,7 @@ 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"
@ -12063,8 +12032,9 @@ mississippi@^3.0.0:
through2 "^2.0.0"
mixin-deep@^1.2.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe"
version "1.3.2"
resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==
dependencies:
for-in "^1.0.2"
is-extendable "^1.0.1"
@ -12965,6 +12935,7 @@ 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"
@ -13387,6 +13358,7 @@ 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"
@ -14375,7 +14347,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.8.4, prismjs@~1.16.0:
prismjs@1.16.0, prismjs@^1.13.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:
@ -15100,6 +15072,12 @@ 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"
@ -16507,55 +16485,81 @@ 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.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==
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"
dependencies:
isomorphic-base64 "^1.0.2"
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==
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"
dependencies:
is-in-browser "^1.1.3"
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==
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"
dependencies:
is-hotkey "0.1.4"
slate-dev-environment "^0.2.2"
is-hotkey "^0.1.1"
slate-dev-environment "^0.1.4"
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:
slate-plain-serializer@0.5.41, slate-plain-serializer@^0.5.17:
version "0.5.41"
resolved "https://registry.yarnpkg.com/slate-prop-types/-/slate-prop-types-0.5.41.tgz#42031881e2fef4fa978a96b9aad84b093b4a5219"
integrity sha512-fLcXlugO9btF5b/by+dA+n8fn2mET75VGWltqFNxGdl6ncyBtrGspWA7mLVRFSqQWOS/Ig4A3URCRumOBBCUfQ==
resolved "https://registry.yarnpkg.com/slate-plain-serializer/-/slate-plain-serializer-0.5.41.tgz#dc2d219602c2cb8dc710ac660e108f3b3cc4dc80"
dependencies:
slate-dev-logger "^0.1.43"
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-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@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==
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"
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"
tiny-invariant "^1.0.1"
tiny-warning "^0.0.3"
slate-dev-logger "^0.1.39"
slate-schema-violations "^0.1.12"
type-of "^2.0.1"
slice-ansi@0.0.4:
@ -17462,20 +17466,14 @@ 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.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-invariant@^1.0.2:
version "1.0.4"
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.4.tgz#346b5415fd93cb696b0c4e8a96697ff590f92463"
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"
@ -17784,7 +17782,6 @@ 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"
@ -18631,6 +18628,7 @@ 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"