Commit 3e8c00da by kay delaney Committed by David

Chore: Moves QueryField to @grafana/ui (#19678)

Closes #19626
parent 69906f73
import React from 'react';
import { storiesOf } from '@storybook/react';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { QueryField } from './QueryField';
const QueryFieldStories = storiesOf('UI/QueryField', module);
QueryFieldStories.addDecorator(withCenteredStory);
QueryFieldStories.add('default', () => {
return <QueryField portalOrigin="mock-origin" query="" />;
});
......@@ -4,17 +4,17 @@ import { QueryField } from './QueryField';
describe('<QueryField />', () => {
it('should render with null initial value', () => {
const wrapper = shallow(<QueryField query={null} />);
const wrapper = shallow(<QueryField query={null} onTypeahead={jest.fn()} portalOrigin="mock-origin" />);
expect(wrapper.find('div').exists()).toBeTruthy();
});
it('should render with empty initial value', () => {
const wrapper = shallow(<QueryField query="" />);
const wrapper = shallow(<QueryField query="" onTypeahead={jest.fn()} portalOrigin="mock-origin" />);
expect(wrapper.find('div').exists()).toBeTruthy();
});
it('should render with initial value', () => {
const wrapper = shallow(<QueryField query="my query" />);
const wrapper = shallow(<QueryField query="my query" onTypeahead={jest.fn()} portalOrigin="mock-origin" />);
expect(wrapper.find('div').exists()).toBeTruthy();
});
});
......@@ -6,16 +6,17 @@ import { Editor, Plugin } from '@grafana/slate-react';
import Plain from 'slate-plain-serializer';
import classnames from 'classnames';
import { CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';
import ClearPlugin from './slate-plugins/clear';
import NewlinePlugin from './slate-plugins/newline';
import SelectionShortcutsPlugin from './slate-plugins/selection_shortcuts';
import IndentationPlugin from './slate-plugins/indentation';
import ClipboardPlugin from './slate-plugins/clipboard';
import RunnerPlugin from './slate-plugins/runner';
import SuggestionsPlugin, { SuggestionsState } from './slate-plugins/suggestions';
import { makeValue, SCHEMA } from '@grafana/ui';
import {
ClearPlugin,
NewlinePlugin,
SelectionShortcutsPlugin,
IndentationPlugin,
ClipboardPlugin,
RunnerPlugin,
SuggestionsPlugin,
} from '../../slate-plugins';
import { makeValue, SCHEMA, CompletionItemGroup, TypeaheadOutput, TypeaheadInput, SuggestionsState } from '../..';
export interface QueryFieldProps {
additionalPlugins?: Plugin[];
......@@ -30,7 +31,7 @@ export interface QueryFieldProps {
onTypeahead?: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>;
onWillApplySuggestion?: (suggestion: string, state: SuggestionsState) => string;
placeholder?: string;
portalOrigin?: string;
portalOrigin: string;
syntax?: string;
syntaxLoaded?: boolean;
}
......@@ -43,15 +44,6 @@ export interface QueryFieldState {
value: Value;
}
export interface TypeaheadInput {
prefix: string;
selection?: Selection;
text: string;
value: Value;
wrapperClasses: string[];
labelKey?: string;
}
/**
* Renders an editor field.
* Pass initial value as initialQuery and listen to changes in props.onValueChanged.
......@@ -60,11 +52,10 @@ export interface TypeaheadInput {
*/
export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldState> {
plugins: Plugin[];
resetTimer: NodeJS.Timer;
mounted: boolean;
runOnChangeDebounced: Function;
editor: Editor;
lastExecutedValue: Value | null = null;
mounted = false;
editor: Editor | null = null;
constructor(props: QueryFieldProps, context: Context<any>) {
super(props, context);
......@@ -100,7 +91,6 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
componentWillUnmount() {
this.mounted = false;
clearTimeout(this.resetTimer);
}
componentDidUpdate(prevProps: QueryFieldProps, prevState: QueryFieldState) {
......@@ -119,6 +109,10 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
UNSAFE_componentWillReceiveProps(nextProps: QueryFieldProps) {
if (nextProps.syntaxLoaded && !this.props.syntaxLoaded) {
if (!this.editor) {
return;
}
// 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);
......@@ -196,7 +190,7 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
<div className={wrapperClassName}>
<div className="slate-query-field">
<Editor
ref={editor => (this.editor = editor)}
ref={editor => (this.editor = editor!)}
schema={SCHEMA}
autoCorrect={false}
readOnly={this.props.disabled}
......
......@@ -3,16 +3,15 @@ import ReactDOM from 'react-dom';
import _ from 'lodash';
import { FixedSizeList } from 'react-window';
import { Themeable, withTheme } from '@grafana/ui';
import { CompletionItem, CompletionItemKind, CompletionItemGroup } from 'app/types/explore';
import { TypeaheadItem } from './TypeaheadItem';
import { TypeaheadInfo } from './TypeaheadInfo';
import { flattenGroupItems, calculateLongestLabel, calculateListSizes } from './utils/typeahead';
import { TypeaheadItem } from './TypeaheadItem';
import { flattenGroupItems, calculateLongestLabel, calculateListSizes } from '../../utils/typeahead';
import { ThemeContext } from '../../themes/ThemeContext';
import { CompletionItem, CompletionItemGroup, CompletionItemKind } from '../../types/completion';
const modulo = (a: number, n: number) => a - n * Math.floor(a / n);
interface Props extends Themeable {
interface Props {
origin: string;
groupedItems: CompletionItemGroup[];
prefix?: string;
......@@ -26,26 +25,33 @@ interface State {
listWidth: number;
listHeight: number;
itemHeight: number;
hoveredItem: number;
hoveredItem: number | null;
typeaheadIndex: number;
}
export class Typeahead extends React.PureComponent<Props, State> {
static contextType = ThemeContext;
context!: React.ContextType<typeof ThemeContext>;
listRef = createRef<FixedSizeList>();
constructor(props: Props) {
super(props);
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 };
}
state: State = { hoveredItem: null, typeaheadIndex: 1, allItems: [], listWidth: -1, listHeight: -1, itemHeight: -1 };
componentDidMount = () => {
this.props.menuRef(this);
if (this.props.menuRef) {
this.props.menuRef(this);
}
document.addEventListener('selectionchange', this.handleSelectionChange);
const allItems = flattenGroupItems(this.props.groupedItems);
const longestLabel = calculateLongestLabel(allItems);
const { listWidth, listHeight, itemHeight } = calculateListSizes(this.context, allItems, longestLabel);
this.setState({
listWidth,
listHeight,
itemHeight,
allItems,
});
};
componentWillUnmount = () => {
......@@ -68,7 +74,7 @@ export class Typeahead extends React.PureComponent<Props, State> {
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);
const { listWidth, listHeight, itemHeight } = calculateListSizes(this.context, allItems, longestLabel);
this.setState({ listWidth, listHeight, itemHeight, allItems });
}
};
......@@ -89,7 +95,6 @@ export class Typeahead extends React.PureComponent<Props, State> {
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) {
......@@ -105,7 +110,9 @@ export class Typeahead extends React.PureComponent<Props, State> {
};
insertSuggestion = () => {
this.props.onSelectSuggestion(this.state.allItems[this.state.typeaheadIndex]);
if (this.props.onSelectSuggestion) {
this.props.onSelectSuggestion(this.state.allItems[this.state.typeaheadIndex]);
}
};
get menuPosition(): string {
......@@ -115,10 +122,10 @@ export class Typeahead extends React.PureComponent<Props, State> {
}
const selection = window.getSelection();
const node = selection.anchorNode;
const node = selection && selection.anchorNode;
// Align menu overlay to editor node
if (node) {
if (node && node.parentElement) {
// Read from DOM
const rect = node.parentElement.getBoundingClientRect();
const scrollX = window.scrollX;
......@@ -133,7 +140,7 @@ export class Typeahead extends React.PureComponent<Props, State> {
}
render() {
const { prefix, theme, isOpen, origin } = this.props;
const { prefix, isOpen = false, origin } = this.props;
const { allItems, listWidth, listHeight, itemHeight, hoveredItem, typeaheadIndex } = this.state;
const showDocumentation = hoveredItem || typeaheadIndex;
......@@ -161,7 +168,7 @@ export class Typeahead extends React.PureComponent<Props, State> {
return (
<TypeaheadItem
onClickItem={() => this.props.onSelectSuggestion(item)}
onClickItem={() => (this.props.onSelectSuggestion ? this.props.onSelectSuggestion(item) : {})}
isSelected={allItems[typeaheadIndex] === item}
item={item}
prefix={prefix}
......@@ -175,20 +182,13 @@ export class Typeahead extends React.PureComponent<Props, State> {
</ul>
{showDocumentation && (
<TypeaheadInfo
width={listWidth}
height={listHeight}
theme={theme}
item={allItems[hoveredItem ? hoveredItem : typeaheadIndex]}
/>
<TypeaheadInfo height={listHeight} item={allItems[hoveredItem ? hoveredItem : typeaheadIndex]} />
)}
</Portal>
);
}
}
export const TypeaheadWithTheme = withTheme(Typeahead);
interface PortalProps {
index?: number;
isOpen: boolean;
......
import React, { PureComponent } from 'react';
import React, { useContext } from 'react';
import { css, cx } from 'emotion';
import { Themeable, selectThemeVariant } from '@grafana/ui';
import { CompletionItem } from 'app/types/explore';
import { CompletionItem, selectThemeVariant, ThemeContext, GrafanaTheme } from '../..';
const getStyles = (theme: GrafanaTheme, height: number, visible: boolean) => {
return {
typeaheadItem: css`
label: type-ahead-item;
padding: ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.md};
border-radius: ${theme.border.radius.md};
border: ${selectThemeVariant(
{ light: `solid 1px ${theme.colors.gray5}`, dark: `solid 1px ${theme.colors.dark1}` },
theme.type
)};
overflow-y: scroll;
overflow-x: hidden;
outline: none;
background: ${selectThemeVariant({ light: theme.colors.white, dark: theme.colors.dark4 }, theme.type)};
color: ${theme.colors.text};
box-shadow: ${selectThemeVariant(
{ light: `0 5px 10px 0 ${theme.colors.gray5}`, dark: `0 5px 10px 0 ${theme.colors.black}` },
theme.type
)};
visibility: ${visible === true ? 'visible' : 'hidden'};
width: 250px;
height: ${height + parseInt(theme.spacing.xxs, 10)}px;
position: relative;
`,
};
};
interface Props extends Themeable {
interface Props {
item: CompletionItem;
width: number;
height: number;
}
export class TypeaheadInfo extends PureComponent<Props> {
constructor(props: Props) {
super(props);
}
getStyles = (visible: boolean) => {
const { height, theme } = this.props;
return {
typeaheadItem: css`
label: type-ahead-item;
padding: ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.md};
border-radius: ${theme.border.radius.md};
border: ${selectThemeVariant(
{ light: `solid 1px ${theme.colors.gray5}`, dark: `solid 1px ${theme.colors.dark1}` },
theme.type
)};
overflow-y: scroll;
overflow-x: hidden;
outline: none;
background: ${selectThemeVariant({ light: theme.colors.white, dark: theme.colors.dark4 }, theme.type)};
color: ${theme.colors.text};
box-shadow: ${selectThemeVariant(
{ light: `0 5px 10px 0 ${theme.colors.gray5}`, dark: `0 5px 10px 0 ${theme.colors.black}` },
theme.type
)};
visibility: ${visible === true ? 'visible' : 'hidden'};
width: 250px;
height: ${height + parseInt(theme.spacing.xxs, 10)}px;
position: relative;
`,
};
};
render() {
const { item } = this.props;
const visible = item && !!item.documentation;
const label = item ? item.label : '';
const documentation = item && item.documentation ? item.documentation : '';
const styles = this.getStyles(visible);
return (
<div className={cx([styles.typeaheadItem])}>
<b>{label}</b>
<hr />
<span>{documentation}</span>
</div>
);
}
}
export const TypeaheadInfo: React.FC<Props> = ({ item, height }) => {
const visible = item && !!item.documentation;
const label = item ? item.label : '';
const documentation = item && item.documentation ? item.documentation : '';
const theme = useContext(ThemeContext);
const styles = getStyles(theme, height, visible);
return (
<div className={cx([styles.typeaheadItem])}>
<b>{label}</b>
<hr />
<span>{documentation}</span>
</div>
);
};
import React, { FunctionComponent, useContext } from 'react';
import React, { 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, CompletionItemKind, GrafanaTheme, ThemeContext, selectThemeVariant } from '../..';
interface Props {
isSelected: boolean;
......@@ -57,7 +56,7 @@ const getStyles = (theme: GrafanaTheme) => ({
`,
});
export const TypeaheadItem: FunctionComponent<Props> = (props: Props) => {
export const TypeaheadItem: React.FC<Props> = (props: Props) => {
const theme = useContext(ThemeContext);
const styles = getStyles(theme);
......
......@@ -38,6 +38,7 @@ export { TimeOfDayPicker } from './TimePicker/TimeOfDayPicker';
export { List } from './List/List';
export { TagsInput } from './TagsInput/TagsInput';
export { Modal } from './Modal/Modal';
export { QueryField } from './QueryField/QueryField';
// Renderless
export { SetInterval } from './SetInterval/SetInterval';
......
......@@ -2,7 +2,7 @@ import React from 'react';
import Plain from 'slate-plain-serializer';
import { Editor } from '@grafana/slate-react';
import { shallow } from 'enzyme';
import BracesPlugin from './braces';
import { BracesPlugin } from './braces';
declare global {
interface Window {
......@@ -11,7 +11,7 @@ declare global {
}
describe('braces', () => {
const handler = BracesPlugin().onKeyDown;
const handler = BracesPlugin().onKeyDown!;
const nextMock = () => {};
it('adds closing braces around empty value', () => {
......
......@@ -7,16 +7,17 @@ const BRACES: any = {
'(': ')',
};
export default function BracesPlugin(): Plugin {
export function BracesPlugin(): Plugin {
return {
onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) {
onKeyDown(event: Event, editor: CoreEditor, next: Function) {
const keyEvent = event as KeyboardEvent;
const { value } = editor;
switch (event.key) {
switch (keyEvent.key) {
case '(':
case '{':
case '[': {
event.preventDefault();
keyEvent.preventDefault();
const {
start: { offset: startOffset, key: startKey },
end: { offset: endOffset, key: endKey },
......@@ -27,17 +28,17 @@ export default function BracesPlugin(): Plugin {
// If text is selected, wrap selected text in parens
if (value.selection.isExpanded) {
editor
.insertTextByKey(startKey, startOffset, event.key)
.insertTextByKey(endKey, endOffset + 1, BRACES[event.key])
.insertTextByKey(startKey, startOffset, keyEvent.key)
.insertTextByKey(endKey, endOffset + 1, BRACES[keyEvent.key])
.moveEndBackward(1);
} else if (
focusOffset === text.length ||
text[focusOffset] === ' ' ||
Object.values(BRACES).includes(text[focusOffset])
) {
editor.insertText(`${event.key}${BRACES[event.key]}`).moveBackward(1);
editor.insertText(`${keyEvent.key}${BRACES[keyEvent.key]}`).moveBackward(1);
} else {
editor.insertText(event.key);
editor.insertText(keyEvent.key);
}
return true;
......@@ -49,7 +50,7 @@ export default function BracesPlugin(): Plugin {
const previousChar = text[offset - 1];
const nextChar = text[offset];
if (BRACES[previousChar] && BRACES[previousChar] === nextChar) {
event.preventDefault();
keyEvent.preventDefault();
// Remove closing brace if directly following
editor
.deleteBackward(1)
......
......@@ -2,10 +2,10 @@ import Plain from 'slate-plain-serializer';
import React from 'react';
import { Editor } from '@grafana/slate-react';
import { shallow } from 'enzyme';
import ClearPlugin from './clear';
import { ClearPlugin } from './clear';
describe('clear', () => {
const handler = ClearPlugin().onKeyDown;
const handler = ClearPlugin().onKeyDown!;
it('does not change the empty value', () => {
const value = Plain.deserialize('');
......
......@@ -2,17 +2,18 @@ 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 function ClearPlugin(): Plugin {
return {
onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) {
onKeyDown(event: Event, editor: CoreEditor, next: Function) {
const keyEvent = event as KeyboardEvent;
const value = editor.value;
if (value.selection.isExpanded) {
return next();
}
if (event.key === 'k' && event.ctrlKey) {
event.preventDefault();
if (keyEvent.key === 'k' && keyEvent.ctrlKey) {
keyEvent.preventDefault();
const text = value.anchorText.text;
const offset = value.selection.anchor.offset;
const length = text.length;
......
......@@ -10,10 +10,11 @@ const getCopiedText = (textBlocks: string[], startOffset: number, endOffset: num
return textBlocks.join('\n').slice(startOffset, excludingLastLineLength + endOffset);
};
export default function ClipboardPlugin(): Plugin {
const clipboardPlugin = {
onCopy(event: ClipboardEvent, editor: CoreEditor) {
event.preventDefault();
export function ClipboardPlugin(): Plugin {
const clipboardPlugin: Plugin = {
onCopy(event: Event, editor: CoreEditor, next: () => any) {
const clipEvent = event as ClipboardEvent;
clipEvent.preventDefault();
const { document, selection } = editor.value;
const {
......@@ -26,22 +27,25 @@ export default function ClipboardPlugin(): Plugin {
.map(block => block.text);
const copiedText = getCopiedText(selectedBlocks, startOffset, endOffset);
if (copiedText) {
event.clipboardData.setData('Text', copiedText);
if (copiedText && clipEvent.clipboardData) {
clipEvent.clipboardData.setData('Text', copiedText);
}
return true;
},
onPaste(event: ClipboardEvent, editor: CoreEditor) {
event.preventDefault();
const pastedValue = event.clipboardData.getData('Text');
const lines = pastedValue.split('\n');
onPaste(event: Event, editor: CoreEditor, next: () => any) {
const clipEvent = event as ClipboardEvent;
clipEvent.preventDefault();
if (clipEvent.clipboardData) {
const pastedValue = clipEvent.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);
if (lines.length) {
editor.insertText(lines[0]);
for (const line of lines.slice(1)) {
editor.splitBlock().insertText(line);
}
}
}
......@@ -51,8 +55,9 @@ export default function ClipboardPlugin(): Plugin {
return {
...clipboardPlugin,
onCut(event: ClipboardEvent, editor: CoreEditor) {
clipboardPlugin.onCopy(event, editor);
onCut(event: Event, editor: CoreEditor, next: () => any) {
const clipEvent = event as ClipboardEvent;
clipboardPlugin.onCopy!(clipEvent, editor, next);
editor.deleteAtRange(editor.value.selection);
return true;
......
......@@ -21,7 +21,7 @@ const handleTabKey = (event: KeyboardEvent, editor: CoreEditor, next: Function):
const first = startBlock.getFirstText();
const startBlockIsSelected =
startOffset === 0 && startKey === first.key && endOffset === first.text.length && endKey === first.key;
first && startOffset === 0 && startKey === first.key && endOffset === first.text.length && endKey === first.key;
if (startBlockIsSelected || !startBlock.equals(endBlock)) {
handleIndent(editor, 'right');
......@@ -38,7 +38,7 @@ const handleIndent = (editor: CoreEditor, indentDirection: 'left' | 'right') =>
for (const block of selectedBlocks) {
const blockWhitespace = block.text.length - block.text.trimLeft().length;
const textKey = block.getFirstText().key;
const textKey = block.getFirstText()!.key;
const rangeProperties: RangeJSON = {
anchor: {
......@@ -61,7 +61,7 @@ const handleIndent = (editor: CoreEditor, indentDirection: 'left' | 'right') =>
const isWhiteSpace = /^\s*$/.test(textBeforeCaret);
for (const block of selectedBlocks) {
editor.insertTextByKey(block.getFirstText().key, 0, SLATE_TAB);
editor.insertTextByKey(block.getFirstText()!.key, 0, SLATE_TAB);
}
if (isWhiteSpace) {
......@@ -71,18 +71,19 @@ const handleIndent = (editor: CoreEditor, indentDirection: 'left' | 'right') =>
};
// Clears the rest of the line after the caret
export default function IndentationPlugin(): Plugin {
export function IndentationPlugin(): Plugin {
return {
onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) {
if (isIndentLeftHotkey(event) || isShiftTabHotkey(event)) {
event.preventDefault();
onKeyDown(event: Event, editor: CoreEditor, next: Function) {
const keyEvent = event as KeyboardEvent;
if (isIndentLeftHotkey(keyEvent) || isShiftTabHotkey(keyEvent)) {
keyEvent.preventDefault();
handleIndent(editor, 'left');
} else if (isIndentRightHotkey(event)) {
event.preventDefault();
} else if (isIndentRightHotkey(keyEvent)) {
keyEvent.preventDefault();
handleIndent(editor, 'right');
} else if (event.key === 'Tab') {
event.preventDefault();
handleTabKey(event, editor, next);
} else if (keyEvent.key === 'Tab') {
keyEvent.preventDefault();
handleTabKey(keyEvent, editor, next);
} else {
return next();
}
......
export { BracesPlugin } from './braces';
export { ClearPlugin } from './clear';
export { ClipboardPlugin } from './clipboard';
export { IndentationPlugin } from './indentation';
export { NewlinePlugin } from './newline';
export { RunnerPlugin } from './runner';
export { SelectionShortcutsPlugin } from './selection_shortcuts';
export { SlatePrism } from './slate-prism';
export { SuggestionsPlugin } from './suggestions';
......@@ -13,17 +13,18 @@ function getIndent(text: string) {
return '';
}
export default function NewlinePlugin(): Plugin {
export function NewlinePlugin(): Plugin {
return {
onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) {
onKeyDown(event: Event, editor: CoreEditor, next: Function) {
const keyEvent = event as KeyboardEvent;
const value = editor.value;
if (value.selection.isExpanded) {
return next();
}
if (event.key === 'Enter' && event.shiftKey) {
event.preventDefault();
if (keyEvent.key === 'Enter' && keyEvent.shiftKey) {
keyEvent.preventDefault();
const { startBlock } = value;
const currentLineText = startBlock.text;
......
......@@ -2,11 +2,11 @@ import Plain from 'slate-plain-serializer';
import React from 'react';
import { Editor } from '@grafana/slate-react';
import { shallow } from 'enzyme';
import RunnerPlugin from './runner';
import { RunnerPlugin } from './runner';
describe('runner', () => {
const mockHandler = jest.fn();
const handler = RunnerPlugin({ handler: mockHandler }).onKeyDown;
const handler = RunnerPlugin({ handler: mockHandler }).onKeyDown!;
it('should execute query when enter is pressed and there are no suggestions visible', () => {
const value = Plain.deserialize('');
......
import { Editor as SlateEditor } from 'slate';
import { Plugin } from '@grafana/slate-react';
import { Editor as CoreEditor } from 'slate';
export default function RunnerPlugin({ handler }: any) {
export function RunnerPlugin({ handler }: any): Plugin {
return {
onKeyDown(event: KeyboardEvent, editor: SlateEditor, next: Function) {
onKeyDown(event: Event, editor: CoreEditor, next: Function) {
const keyEvent = event as KeyboardEvent;
// Handle enter
if (handler && event.key === 'Enter' && !event.shiftKey) {
if (handler && keyEvent.key === 'Enter' && !keyEvent.shiftKey) {
// Submit on Enter
event.preventDefault();
handler(event);
keyEvent.preventDefault();
handler(keyEvent);
return true;
}
......
......@@ -6,11 +6,12 @@ import { isKeyHotkey } from 'is-hotkey';
const isSelectLineHotkey = isKeyHotkey('mod+l');
// Clears the rest of the line after the caret
export default function SelectionShortcutsPlugin(): Plugin {
export function SelectionShortcutsPlugin(): Plugin {
return {
onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) {
if (isSelectLineHotkey(event)) {
event.preventDefault();
onKeyDown(event: Event, editor: CoreEditor, next: () => any) {
const keyEvent = event as KeyboardEvent;
if (isSelectLineHotkey(keyEvent)) {
keyEvent.preventDefault();
const { focusBlock, document } = editor.value;
editor.moveAnchorToStartOfBlock();
......
......@@ -4,14 +4,17 @@ 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 { 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';
import TOKEN_MARK from './slate-prism/TOKEN_MARK';
import {
makeFragment,
TypeaheadOutput,
CompletionItem,
TypeaheadInput,
SuggestionsState,
CompletionItemGroup,
} from '..';
import { Typeahead } from '../components/Typeahead/Typeahead';
export const TYPEAHEAD_DEBOUNCE = 100;
// Commands added to the editor by this plugin.
......@@ -27,13 +30,13 @@ export interface SuggestionsState {
typeaheadText: string;
}
export default function SuggestionsPlugin({
export function SuggestionsPlugin({
onTypeahead,
cleanText,
onWillApplySuggestion,
portalOrigin,
}: {
onTypeahead: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>;
onTypeahead?: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>;
cleanText?: (text: string) => string;
onWillApplySuggestion?: (suggestion: string, state: SuggestionsState) => string;
portalOrigin: string;
......@@ -73,15 +76,16 @@ export default function SuggestionsPlugin({
return next();
},
onKeyDown: (event: KeyboardEvent, editor, next) => {
onKeyDown: (event: Event, editor, next) => {
const keyEvent = event as KeyboardEvent;
const currentSuggestions = state.groupedItems;
const hasSuggestions = currentSuggestions.length;
switch (event.key) {
switch (keyEvent.key) {
case 'Escape': {
if (hasSuggestions) {
event.preventDefault();
keyEvent.preventDefault();
state = {
...state,
......@@ -98,8 +102,8 @@ export default function SuggestionsPlugin({
case 'ArrowDown':
case 'ArrowUp':
if (hasSuggestions) {
event.preventDefault();
typeaheadRef.moveMenuIndex(event.key === 'ArrowDown' ? 1 : -1);
keyEvent.preventDefault();
typeaheadRef.moveMenuIndex(keyEvent.key === 'ArrowDown' ? 1 : -1);
return;
}
......@@ -108,7 +112,7 @@ export default function SuggestionsPlugin({
case 'Enter':
case 'Tab': {
if (hasSuggestions) {
event.preventDefault();
keyEvent.preventDefault();
return typeaheadRef.insertSuggestion();
}
......@@ -196,8 +200,8 @@ export default function SuggestionsPlugin({
return (
<>
{children}
<TypeaheadWithTheme
menuRef={(el: Typeahead) => (typeaheadRef = el)}
<Typeahead
menuRef={(menu: Typeahead) => (typeaheadRef = menu)}
origin={portalOrigin}
prefix={state.typeaheadPrefix}
isOpen={!!state.groupedItems.length}
......@@ -217,7 +221,7 @@ const handleTypeahead = async (
cleanText?: (text: string) => string
): Promise<void> => {
if (!onTypeahead) {
return null;
return;
}
const { value } = editor;
......@@ -226,25 +230,28 @@ const handleTypeahead = async (
// 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 decorations = parentBlock && parentBlock.getDecorations(editor as any);
const filteredDecorations = decorations
.filter(
decoration =>
decoration.start.offset <= myOffset && decoration.end.offset > myOffset && decoration.type === TOKEN_MARK
)
.toArray();
? decorations
.filter(
decoration =>
decoration!.start.offset <= myOffset && decoration!.end.offset > myOffset && decoration!.type === TOKEN_MARK
)
.toArray()
: [];
// Find the first label key to the left of the cursor
const labelKeyDec = decorations
.filter(decoration => {
return (
decoration.end.offset <= myOffset &&
decoration.type === TOKEN_MARK &&
decoration.data.get('className').includes('label-key')
);
})
.last();
const labelKeyDec =
decorations &&
decorations
.filter(
decoration =>
decoration!.end.offset <= myOffset &&
decoration!.type === TOKEN_MARK &&
decoration!.data.get('className').includes('label-key')
)
.last();
const labelKey = labelKeyDec && value.focusText.text.slice(labelKeyDec.start.offset, labelKeyDec.end.offset);
......@@ -276,7 +283,7 @@ const handleTypeahead = async (
text,
value,
wrapperClasses,
labelKey,
labelKey: labelKey || undefined,
});
const filteredSuggestions = suggestions
......
......@@ -9,6 +9,7 @@ type Subtract<T, K> = Omit<T, keyof K>;
// Use Grafana Dark theme by default
export const ThemeContext = React.createContext(getTheme(GrafanaThemeType.Dark));
ThemeContext.displayName = 'ThemeContext';
export const withTheme = <P extends Themeable, S extends {} = {}>(Component: React.ComponentType<P>) => {
const WithTheme: React.FunctionComponent<Subtract<P, Themeable>> = props => {
......
import { Value } from 'slate';
import { Editor } from '@grafana/slate-react';
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.
*/
skipSort?: boolean;
}
export enum CompletionItemKind {
GroupTitle = 'GroupTitle',
}
export interface CompletionItem {
/**
* The label of this completion item. By default
* this is also the text that is inserted when selecting
* this completion.
*/
label: string;
/**
* The kind of this completion item. An icon is chosen
* by the editor based on the kind.
*/
kind?: CompletionItemKind | string;
/**
* A human-readable string with additional information
* about this item, like type or symbol information.
*/
detail?: string;
/**
* A human-readable string, can be Markdown, that represents a doc-comment.
*/
documentation?: string;
/**
* A string that should be used when comparing this item
* with other items. When `falsy` the `label` is used.
*/
sortText?: string;
/**
* A string that should be used when filtering a set of
* completion items. When `falsy` the `label` is used.
*/
filterText?: string;
/**
* A string or snippet that should be inserted in a document when selecting
* this completion. When `falsy` the `label` is used.
*/
insertText?: string;
/**
* Delete number of characters before the caret position,
* by default the letters from the beginning of the word.
*/
deleteBackwards?: number;
/**
* Number of steps to move after the insertion, can be negative.
*/
move?: number;
}
export interface TypeaheadOutput {
context?: string;
suggestions: CompletionItemGroup[];
}
export interface TypeaheadInput {
text: string;
prefix: string;
wrapperClasses: string[];
labelKey?: string;
value?: Value;
editor?: Editor;
}
export interface SuggestionsState {
groupedItems: CompletionItemGroup[];
typeaheadPrefix: string;
typeaheadContext: string;
typeaheadText: string;
}
......@@ -549,3 +549,19 @@ export interface AnnotationQueryRequest<MoreOptions = {}> {
name: string;
} & MoreOptions;
}
export interface HistoryItem<TQuery extends DataQuery = DataQuery> {
ts: number;
query: TQuery;
}
export abstract class LanguageProvider {
datasource!: DataSourceApi;
request!: (url: string, params?: any) => Promise<any>;
/**
* Returns startTask that resolves with a task list when main syntax is loaded.
* Task list consists of secondary promises that load more detailed language features.
*/
start!: () => Promise<any[]>;
startTask?: Promise<any[]>;
}
export * from './panel';
export * from './plugin';
export * from './app';
export * from './completion';
export * from './datasource';
export * from './theme';
export * from './input';
export * from './panel';
export * from './plugin';
export * from './theme';
import * as PanelEvents from './events';
export { PanelEvents };
import { GrafanaTheme } from '@grafana/ui';
import { default as calculateSize } from 'calculate-size';
import { CompletionItemGroup, CompletionItem, CompletionItemKind } from 'app/types';
import { CompletionItemGroup, CompletionItem, CompletionItemKind } from '../types/completion';
import { GrafanaTheme } from '..';
export const flattenGroupItems = (groupedItems: CompletionItemGroup[]): CompletionItem[] => {
return groupedItems.reduce((all, current) => {
......@@ -10,7 +9,7 @@ export const flattenGroupItems = (groupedItems: CompletionItemGroup[]): Completi
kind: CompletionItemKind.GroupTitle,
};
return all.concat(titleItem, current.items);
}, []);
}, new Array<CompletionItem>());
};
export const calculateLongestLabel = (allItems: CompletionItem[]): string => {
......
......@@ -19,9 +19,18 @@ import { renderUrl } from 'app/core/utils/url';
import store from 'app/core/store';
import kbn from 'app/core/utils/kbn';
import { getNextRefIdChar } from './query';
// Types
import { DataQuery, DataSourceApi, DataQueryError, DataQueryRequest, PanelModel, RefreshPicker } from '@grafana/ui';
import { ExploreUrlState, HistoryItem, QueryTransaction, QueryOptions, ExploreMode } from 'app/types/explore';
import {
DataQuery,
DataSourceApi,
DataQueryError,
DataQueryRequest,
PanelModel,
RefreshPicker,
HistoryItem,
} from '@grafana/ui';
import { ExploreUrlState, QueryTransaction, QueryOptions, ExploreMode } from 'app/types/explore';
import { config } from '../config';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
......
......@@ -13,8 +13,8 @@ import { changeQuery, modifyQueries, runQueries, addQueryRow } from './state/act
// Types
import { StoreState } from 'app/types';
import { TimeRange, AbsoluteTimeRange, LoadingState } from '@grafana/data';
import { DataQuery, DataSourceApi, QueryFixAction, PanelData } from '@grafana/ui';
import { HistoryItem, ExploreItemState, ExploreId, ExploreMode } from 'app/types/explore';
import { DataQuery, DataSourceApi, QueryFixAction, PanelData, HistoryItem } from '@grafana/ui';
import { ExploreItemState, ExploreId, ExploreMode } from 'app/types/explore';
import { Emitter } from 'app/core/utils/emitter';
import { highlightLogsExpressionAction, removeQueryRowAction } from './state/actionTypes';
import QueryStatus from './QueryStatus';
......
// Types
import { Unsubscribable } from 'rxjs';
import { Emitter } from 'app/core/core';
import { DataQuery, DataSourceSelectItem, DataSourceApi, QueryFixAction, PanelData } from '@grafana/ui';
import { DataQuery, DataSourceSelectItem, DataSourceApi, QueryFixAction, PanelData, HistoryItem } from '@grafana/ui';
import { LogLevel, TimeRange, LoadingState, AbsoluteTimeRange } from '@grafana/data';
import { ExploreId, ExploreItemState, HistoryItem, ExploreUIState, ExploreMode } from 'app/types/explore';
import { ExploreId, ExploreItemState, ExploreUIState, ExploreMode } from 'app/types/explore';
import { actionCreatorFactory, ActionOf } from 'app/core/redux/actionCreatorFactory';
/** Higher order actions
......
......@@ -4,8 +4,7 @@ import React from 'react';
import { SlatePrism } from '@grafana/ui';
// dom also includes Element polyfills
import QueryField from 'app/features/explore/QueryField';
import { ExploreQueryFieldProps } from '@grafana/ui';
import { QueryField, ExploreQueryFieldProps } from '@grafana/ui';
import { ElasticDatasource } from '../datasource';
import { ElasticsearchOptions, ElasticsearchQuery } from '../types';
......
import PluginPrism from 'app/features/explore/slate-plugins/prism';
import BracesPlugin from 'app/features/explore/slate-plugins/braces';
import ClearPlugin from 'app/features/explore/slate-plugins/clear';
import NewlinePlugin from 'app/features/explore/slate-plugins/newline';
import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
import { BracesPlugin, ClearPlugin, RunnerPlugin, NewlinePlugin } from '@grafana/ui';
import Typeahead from './typeahead';
import { getKeybindingSrv, KeybindingSrv } from 'app/core/services/keybindingSrv';
......
......@@ -3,23 +3,18 @@ import React from 'react';
// @ts-ignore
import Cascader from 'rc-cascader';
import { SlatePrism } from '@grafana/ui';
import { SlatePrism, TypeaheadOutput, SuggestionsState, QueryField, TypeaheadInput, BracesPlugin } from '@grafana/ui';
// Components
import QueryField, { TypeaheadInput } from 'app/features/explore/QueryField';
// Utils & Services
// dom also includes Element polyfills
import BracesPlugin from 'app/features/explore/slate-plugins/braces';
import { Plugin, Node } from 'slate';
// Types
import { LokiQuery } from '../types';
import { TypeaheadOutput } from 'app/types/explore';
import { ExploreQueryFieldProps, 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';
import LokiDatasource from '../datasource';
function getChooserText(hasSyntax: boolean, hasLogLabels: boolean) {
......
......@@ -3,10 +3,10 @@ import { Editor as SlateEditor } from 'slate';
import LanguageProvider, { LABEL_REFRESH_INTERVAL, LokiHistoryItem, rangeToParams } from './language_provider';
import { AbsoluteTimeRange } from '@grafana/data';
import { TypeaheadInput } from '@grafana/ui';
import { advanceTo, clear, advanceBy } from 'jest-date-mock';
import { beforeEach } from 'test/lib/common';
import { TypeaheadInput } from '../../../types';
import { makeMockLokiDatasource } from './mocks';
import LokiDatasource from './datasource';
......
......@@ -6,12 +6,12 @@ import { parseSelector, labelRegexp, selectorRegexp } from 'app/plugins/datasour
import syntax from './syntax';
// Types
import { CompletionItem, LanguageProvider, TypeaheadInput, TypeaheadOutput, HistoryItem } from 'app/types/explore';
import { LokiQuery } from './types';
import { dateTime, AbsoluteTimeRange } from '@grafana/data';
import { PromQuery } from '../prometheus/types';
import LokiDatasource from './datasource';
import { CompletionItem, TypeaheadInput, TypeaheadOutput, LanguageProvider, HistoryItem } from '@grafana/ui';
const DEFAULT_KEYS = ['job', 'namespace'];
const EMPTY_SELECTOR = '{}';
......
......@@ -3,21 +3,19 @@ import React from 'react';
// @ts-ignore
import Cascader from 'rc-cascader';
import { SlatePrism } from '@grafana/ui';
import { Plugin } from 'slate';
import { SlatePrism, TypeaheadInput, TypeaheadOutput, QueryField, BracesPlugin, HistoryItem } from '@grafana/ui';
import Prism from 'prismjs';
import { TypeaheadOutput, HistoryItem } from 'app/types/explore';
// dom also includes Element polyfills
import BracesPlugin from 'app/features/explore/slate-plugins/braces';
import QueryField, { TypeaheadInput } from 'app/features/explore/QueryField';
import { PromQuery, PromContext, PromOptions } from '../types';
import { CancelablePromise, makePromiseCancelable } from 'app/core/utils/CancelablePromise';
import { ExploreQueryFieldProps, QueryHint, DOMUtil } from '@grafana/ui';
import { isDataFrame, toLegacyResponseData } from '@grafana/data';
import { SuggestionsState } from '@grafana/ui';
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';
......@@ -114,7 +112,7 @@ interface PromQueryFieldState {
}
class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryFieldState> {
plugins: any[];
plugins: Plugin[];
languageProvider: PromQlLanguageProvider;
languageProviderInitializationPromise: CancelablePromise<any>;
......
import _ from 'lodash';
import { dateTime } from '@grafana/data';
import {
CompletionItem,
CompletionItemGroup,
LanguageProvider,
TypeaheadInput,
TypeaheadOutput,
CompletionItemGroup,
LanguageProvider,
HistoryItem,
} from 'app/types/explore';
} from '@grafana/ui';
import { parseSelector, processLabels, processHistogramLabels } from './language_utils';
import PromqlSyntax, { FUNCTIONS, RATE_RANGES } from './promql';
......
/* tslint:disable max-line-length */
import { CompletionItem } from 'app/types/explore';
import { CompletionItem } from '@grafana/ui';
export const RATE_RANGES: CompletionItem[] = [
{ label: '$__interval', sortText: '$__interval' },
......
......@@ -2,7 +2,7 @@ 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 { HistoryItem } from '@grafana/ui';
import { PromQuery } from '../types';
describe('Language completion provider', () => {
......
......@@ -8,6 +8,7 @@ import {
ExploreStartPageProps,
PanelData,
DataQueryRequest,
HistoryItem,
} from '@grafana/ui';
import {
......@@ -23,100 +24,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
* this is also the text that is inserted when selecting
* this completion.
*/
label: string;
/**
* The kind of this completion item. An icon is chosen
* by the editor based on the kind.
*/
kind?: CompletionItemKind | string;
/**
* A human-readable string with additional information
* about this item, like type or symbol information.
*/
detail?: string;
/**
* A human-readable string, can be Markdown, that represents a doc-comment.
*/
documentation?: string;
/**
* A string that should be used when comparing this item
* with other items. When `falsy` the `label` is used.
*/
sortText?: string;
/**
* A string that should be used when filtering a set of
* completion items. When `falsy` the `label` is used.
*/
filterText?: string;
/**
* A string or snippet that should be inserted in a document when selecting
* this completion. When `falsy` the `label` is used.
*/
insertText?: string;
/**
* Delete number of characters before the caret position,
* by default the letters from the beginning of the word.
*/
deleteBackwards?: number;
/**
* Number of steps to move after the insertion, can be negative.
*/
move?: number;
}
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.
*/
skipSort?: boolean;
}
export enum ExploreId {
left = 'left',
right = 'right',
......@@ -308,36 +220,6 @@ export interface ExploreUrlState {
context?: string;
}
export interface HistoryItem<TQuery extends DataQuery = DataQuery> {
ts: number;
query: TQuery;
}
export abstract class LanguageProvider {
datasource: DataSourceApi;
request: (url: string, params?: any) => Promise<any>;
/**
* Returns startTask that resolves with a task list when main syntax is loaded.
* Task list consists of secondary promises that load more detailed language features.
*/
start: () => Promise<any[]>;
startTask?: Promise<any[]>;
}
export interface TypeaheadInput {
text: string;
prefix: string;
wrapperClasses: string[];
labelKey?: string;
value?: Value;
editor?: Editor;
}
export interface TypeaheadOutput {
context?: string;
suggestions: CompletionItemGroup[];
}
export interface QueryIntervals {
interval: string;
intervalMs: number;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment