mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
@@ -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';
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from 'react';
|
||||
import { shallow } from 'enzyme';
|
||||
import { QueryField } from './QueryField';
|
||||
|
||||
describe('<QueryField />', () => {
|
||||
it('should render with null initial value', () => {
|
||||
const wrapper = shallow(<QueryField query={null} />);
|
||||
expect(wrapper.find('div').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render with empty initial value', () => {
|
||||
const wrapper = shallow(<QueryField query="" />);
|
||||
expect(wrapper.find('div').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render with initial value', () => {
|
||||
const wrapper = shallow(<QueryField query="my query" />);
|
||||
expect(wrapper.find('div').exists()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import React, { Context } from 'react';
|
||||
|
||||
import { Value, Editor as CoreEditor } from 'slate';
|
||||
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';
|
||||
|
||||
export interface QueryFieldProps {
|
||||
additionalPlugins?: Plugin[];
|
||||
cleanText?: (text: string) => string;
|
||||
disabled?: boolean;
|
||||
// We have both value and local state. This is usually an antipattern but we need to keep local state
|
||||
// for perf reasons and also have outside value in for example in Explore redux that is mutable from logs
|
||||
// creating a two way binding.
|
||||
query: string | null;
|
||||
onRunQuery?: () => void;
|
||||
onChange?: (value: string) => void;
|
||||
onTypeahead?: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>;
|
||||
onWillApplySuggestion?: (suggestion: string, state: SuggestionsState) => string;
|
||||
placeholder?: string;
|
||||
portalOrigin?: string;
|
||||
syntax?: string;
|
||||
syntaxLoaded?: boolean;
|
||||
}
|
||||
|
||||
export interface QueryFieldState {
|
||||
suggestions: CompletionItemGroup[];
|
||||
typeaheadContext: string | null;
|
||||
typeaheadPrefix: string;
|
||||
typeaheadText: string;
|
||||
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.
|
||||
* This component can only process strings. Internally it uses Slate Value.
|
||||
* Implement props.onTypeahead to use suggestions, see PromQueryField.tsx as an example.
|
||||
*/
|
||||
export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldState> {
|
||||
plugins: Plugin[];
|
||||
resetTimer: NodeJS.Timer;
|
||||
mounted: boolean;
|
||||
runOnChangeDebounced: Function;
|
||||
editor: Editor;
|
||||
lastExecutedValue: Value | null = null;
|
||||
|
||||
constructor(props: QueryFieldProps, context: Context<any>) {
|
||||
super(props, context);
|
||||
|
||||
this.runOnChangeDebounced = _.debounce(this.runOnChange, 500);
|
||||
|
||||
const { onTypeahead, cleanText, portalOrigin, onWillApplySuggestion } = props;
|
||||
|
||||
// Base plugins
|
||||
this.plugins = [
|
||||
NewlinePlugin(),
|
||||
SuggestionsPlugin({ onTypeahead, cleanText, portalOrigin, onWillApplySuggestion }),
|
||||
ClearPlugin(),
|
||||
RunnerPlugin({ handler: this.runOnChangeAndRunQuery }),
|
||||
SelectionShortcutsPlugin(),
|
||||
IndentationPlugin(),
|
||||
ClipboardPlugin(),
|
||||
...(props.additionalPlugins || []),
|
||||
].filter(p => p);
|
||||
|
||||
this.state = {
|
||||
suggestions: [],
|
||||
typeaheadContext: null,
|
||||
typeaheadPrefix: '',
|
||||
typeaheadText: '',
|
||||
value: makeValue(props.query || '', props.syntax),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false;
|
||||
clearTimeout(this.resetTimer);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: QueryFieldProps, prevState: QueryFieldState) {
|
||||
const { query, syntax } = this.props;
|
||||
const { value } = this.state;
|
||||
|
||||
// Handle two way binging between local state and outside prop.
|
||||
// if query changed from the outside
|
||||
if (query !== prevProps.query) {
|
||||
// and we have a version that differs
|
||||
if (query !== Plain.serialize(value)) {
|
||||
this.setState({ value: makeValue(query || '', syntax) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update local state, propagate change upstream and optionally run the query afterwards.
|
||||
*/
|
||||
onChange = (value: Value, runQuery?: boolean) => {
|
||||
const documentChanged = value.document !== this.state.value.document;
|
||||
const prevValue = this.state.value;
|
||||
|
||||
// Update local state with new value and optionally change value upstream.
|
||||
this.setState({ value }, () => {
|
||||
// The diff is needed because the actual value of editor have much more metadata (for example text selection)
|
||||
// that is not passed upstream so every change of editor value does not mean change of the query text.
|
||||
if (documentChanged) {
|
||||
const textChanged = Plain.serialize(prevValue) !== Plain.serialize(value);
|
||||
if (textChanged && runQuery) {
|
||||
this.runOnChangeAndRunQuery();
|
||||
}
|
||||
if (textChanged && !runQuery) {
|
||||
// Debounce change propagation by default for perf reasons.
|
||||
this.runOnChangeDebounced();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
runOnChange = () => {
|
||||
const { onChange } = this.props;
|
||||
|
||||
if (onChange) {
|
||||
onChange(Plain.serialize(this.state.value));
|
||||
}
|
||||
};
|
||||
|
||||
runOnRunQuery = () => {
|
||||
const { onRunQuery } = this.props;
|
||||
|
||||
if (onRunQuery) {
|
||||
onRunQuery();
|
||||
this.lastExecutedValue = this.state.value;
|
||||
}
|
||||
};
|
||||
|
||||
runOnChangeAndRunQuery = () => {
|
||||
// onRunQuery executes query from Redux in Explore so it needs to be updated sync in case we want to run
|
||||
// the query.
|
||||
this.runOnChange();
|
||||
this.runOnRunQuery();
|
||||
};
|
||||
|
||||
/**
|
||||
* We need to handle blur events here mainly because of dashboard panels which expect to have query executed on blur.
|
||||
*/
|
||||
handleBlur = (event: Event, editor: CoreEditor, next: Function) => {
|
||||
const previousValue = this.lastExecutedValue ? Plain.serialize(this.lastExecutedValue) : null;
|
||||
const currentValue = Plain.serialize(editor.value);
|
||||
|
||||
if (previousValue !== currentValue) {
|
||||
this.runOnChangeAndRunQuery();
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { disabled } = this.props;
|
||||
const wrapperClassName = classnames('slate-query-field__wrapper', {
|
||||
'slate-query-field__wrapper--disabled': disabled,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<div className="slate-query-field">
|
||||
<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);
|
||||
}}
|
||||
placeholder={this.props.placeholder}
|
||||
plugins={this.plugins}
|
||||
spellCheck={false}
|
||||
value={this.state.value}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default QueryField;
|
||||
@@ -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';
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import React, { createRef } from 'react';
|
||||
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';
|
||||
|
||||
const modulo = (a: number, n: number) => a - n * Math.floor(a / n);
|
||||
|
||||
interface Props extends Themeable {
|
||||
origin: string;
|
||||
groupedItems: CompletionItemGroup[];
|
||||
prefix?: string;
|
||||
menuRef?: (el: Typeahead) => void;
|
||||
onSelectSuggestion?: (suggestion: CompletionItem) => void;
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
allItems: CompletionItem[];
|
||||
listWidth: number;
|
||||
listHeight: number;
|
||||
itemHeight: number;
|
||||
hoveredItem: number;
|
||||
typeaheadIndex: number;
|
||||
}
|
||||
|
||||
export class Typeahead extends React.PureComponent<Props, State> {
|
||||
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 };
|
||||
}
|
||||
|
||||
componentDidMount = () => {
|
||||
this.props.menuRef(this);
|
||||
|
||||
document.addEventListener('selectionchange', this.handleSelectionChange);
|
||||
};
|
||||
|
||||
componentWillUnmount = () => {
|
||||
document.removeEventListener('selectionchange', this.handleSelectionChange);
|
||||
};
|
||||
|
||||
handleSelectionChange = () => {
|
||||
this.forceUpdate();
|
||||
};
|
||||
|
||||
componentDidUpdate = (prevProps: Readonly<Props>, prevState: Readonly<State>) => {
|
||||
if (prevState.typeaheadIndex !== this.state.typeaheadIndex && this.listRef && this.listRef.current) {
|
||||
if (this.state.typeaheadIndex === 1) {
|
||||
this.listRef.current.scrollToItem(0); // special case for handling the first group label
|
||||
return;
|
||||
}
|
||||
this.listRef.current.scrollToItem(this.state.typeaheadIndex);
|
||||
}
|
||||
|
||||
if (_.isEqual(prevProps.groupedItems, this.props.groupedItems) === false) {
|
||||
const allItems = flattenGroupItems(this.props.groupedItems);
|
||||
const longestLabel = calculateLongestLabel(allItems);
|
||||
const { listWidth, listHeight, itemHeight } = calculateListSizes(this.props.theme, allItems, longestLabel);
|
||||
this.setState({ listWidth, listHeight, itemHeight, allItems });
|
||||
}
|
||||
};
|
||||
|
||||
onMouseEnter = (index: number) => {
|
||||
this.setState({
|
||||
hoveredItem: index,
|
||||
});
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
this.setState({
|
||||
hoveredItem: null,
|
||||
});
|
||||
};
|
||||
|
||||
moveMenuIndex = (moveAmount: number) => {
|
||||
const itemCount = this.state.allItems.length;
|
||||
if (itemCount) {
|
||||
// Select next suggestion
|
||||
event.preventDefault();
|
||||
let newTypeaheadIndex = modulo(this.state.typeaheadIndex + moveAmount, itemCount);
|
||||
|
||||
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(): string {
|
||||
// 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 `position: absolute; display: flex; top: ${rect.top + scrollY + rect.height + 6}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;
|
||||
|
||||
return (
|
||||
<Portal origin={origin} isOpen={isOpen} style={this.menuPosition}>
|
||||
<ul className="typeahead">
|
||||
<FixedSizeList
|
||||
ref={this.listRef}
|
||||
itemCount={allItems.length}
|
||||
itemSize={itemHeight}
|
||||
itemKey={index => {
|
||||
const item = allItems && allItems[index];
|
||||
const key = item ? `${index}-${item.label}` : `${index}`;
|
||||
return key;
|
||||
}}
|
||||
width={listWidth}
|
||||
height={listHeight}
|
||||
>
|
||||
{({ index, style }) => {
|
||||
const item = allItems && allItems[index];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TypeaheadItem
|
||||
onClickItem={() => this.props.onSelectSuggestion(item)}
|
||||
isSelected={allItems[typeaheadIndex] === item}
|
||||
item={item}
|
||||
prefix={prefix}
|
||||
style={style}
|
||||
onMouseEnter={() => this.onMouseEnter(index)}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FixedSizeList>
|
||||
</ul>
|
||||
|
||||
{showDocumentation && (
|
||||
<TypeaheadInfo
|
||||
width={listWidth}
|
||||
height={listHeight}
|
||||
theme={theme}
|
||||
item={allItems[hoveredItem ? hoveredItem : typeaheadIndex]}
|
||||
/>
|
||||
)}
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const TypeaheadWithTheme = withTheme(Typeahead);
|
||||
|
||||
interface PortalProps {
|
||||
index?: number;
|
||||
isOpen: boolean;
|
||||
origin: string;
|
||||
style: string;
|
||||
}
|
||||
|
||||
class Portal extends React.PureComponent<PortalProps, {}> {
|
||||
node: HTMLElement;
|
||||
|
||||
constructor(props: PortalProps) {
|
||||
super(props);
|
||||
const { index = 0, origin = 'query', style } = props;
|
||||
this.node = document.createElement('div');
|
||||
this.node.setAttribute('style', style);
|
||||
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) {
|
||||
this.node.setAttribute('style', this.props.style);
|
||||
this.node.classList.add(`slate-typeahead--open`);
|
||||
return ReactDOM.createPortal(this.props.children, this.node);
|
||||
} else {
|
||||
this.node.classList.remove(`slate-typeahead--open`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
|
||||
import { Themeable, selectThemeVariant } from '@grafana/ui';
|
||||
|
||||
import { CompletionItem } from 'app/types/explore';
|
||||
|
||||
interface Props extends Themeable {
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
isSelected: boolean;
|
||||
item: CompletionItem;
|
||||
style: any;
|
||||
prefix?: string;
|
||||
|
||||
onClickItem?: (event: React.MouseEvent) => void;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => ({
|
||||
typeaheadItem: css`
|
||||
label: type-ahead-item;
|
||||
height: auto;
|
||||
font-family: ${theme.typography.fontFamily.monospace};
|
||||
padding: ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.md};
|
||||
font-size: ${theme.typography.size.sm};
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
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};
|
||||
border-bottom: 1px solid ${theme.colors.yellow};
|
||||
padding: inherit;
|
||||
background: inherit;
|
||||
`,
|
||||
|
||||
typeaheadItemGroupTitle: css`
|
||||
label: type-ahead-item-group-title;
|
||||
color: ${theme.colors.textWeak};
|
||||
font-size: ${theme.typography.size.sm};
|
||||
line-height: ${theme.typography.lineHeight.lg};
|
||||
padding: ${theme.spacing.sm};
|
||||
`,
|
||||
});
|
||||
|
||||
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 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) {
|
||||
return (
|
||||
<li className={itemGroupTitleClassName} style={style}>
|
||||
<span>{label}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className={className}
|
||||
style={style}
|
||||
onMouseDown={onClickItem}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<Highlighter textToHighlight={label} searchWords={[prefix]} highlightClassName={highlightClassName} />
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Plugin } from '@grafana/slate-react';
|
||||
import { Editor as CoreEditor } from 'slate';
|
||||
|
||||
const BRACES: any = {
|
||||
'[': ']',
|
||||
'{': '}',
|
||||
'(': ')',
|
||||
};
|
||||
|
||||
export default function BracesPlugin(): Plugin {
|
||||
return {
|
||||
onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) {
|
||||
const { value } = editor;
|
||||
|
||||
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;
|
||||
|
||||
// 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])
|
||||
.moveEndBackward(1);
|
||||
} else if (
|
||||
focusOffset === text.length ||
|
||||
text[focusOffset] === ' ' ||
|
||||
Object.values(BRACES).includes(text[focusOffset])
|
||||
) {
|
||||
editor.insertText(`${event.key}${BRACES[event.key]}`).moveBackward(1);
|
||||
} else {
|
||||
editor.insertText(event.key);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'Backspace': {
|
||||
const text = value.anchorText.text;
|
||||
const offset = value.selection.anchor.offset;
|
||||
const previousChar = text[offset - 1];
|
||||
const nextChar = text[offset];
|
||||
if (BRACES[previousChar] && BRACES[previousChar] === nextChar) {
|
||||
event.preventDefault();
|
||||
// Remove closing brace if directly following
|
||||
editor
|
||||
.deleteBackward(1)
|
||||
.deleteForward(1)
|
||||
.focus();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 ');
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
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 {
|
||||
return {
|
||||
onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) {
|
||||
const value = editor.value;
|
||||
|
||||
if (value.selection.isExpanded) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (event.key === 'k' && event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
const text = value.anchorText.text;
|
||||
const offset = value.selection.anchor.offset;
|
||||
const length = text.length;
|
||||
const forward = length - offset;
|
||||
editor.deleteForward(forward);
|
||||
return true;
|
||||
}
|
||||
|
||||
return next();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Plugin } from '@grafana/slate-react';
|
||||
import { Editor as CoreEditor } from 'slate';
|
||||
|
||||
function getIndent(text: string) {
|
||||
let offset = text.length - text.trimLeft().length;
|
||||
if (offset) {
|
||||
let indent = text[0];
|
||||
while (--offset) {
|
||||
indent += text[0];
|
||||
}
|
||||
return indent;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function NewlinePlugin(): Plugin {
|
||||
return {
|
||||
onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) {
|
||||
const value = editor.value;
|
||||
|
||||
if (value.selection.isExpanded) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
const { startBlock } = value;
|
||||
const currentLineText = startBlock.text;
|
||||
const indent = getIndent(currentLineText);
|
||||
|
||||
return editor
|
||||
.splitBlock()
|
||||
.insertText(indent)
|
||||
.focus();
|
||||
}
|
||||
|
||||
return next();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Editor as SlateEditor } from 'slate';
|
||||
|
||||
export default function RunnerPlugin({ handler }: any) {
|
||||
return {
|
||||
onKeyDown(event: KeyboardEvent, editor: SlateEditor, next: Function) {
|
||||
// Handle enter
|
||||
if (handler && event.key === 'Enter' && !event.shiftKey) {
|
||||
// Submit on Enter
|
||||
event.preventDefault();
|
||||
handler(event);
|
||||
return true;
|
||||
}
|
||||
|
||||
return next();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Plugin } from '@grafana/slate-react';
|
||||
import { Editor as CoreEditor } from 'slate';
|
||||
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
|
||||
const isSelectLineHotkey = isKeyHotkey('mod+l');
|
||||
|
||||
// Clears the rest of the line after the caret
|
||||
export default function SelectionShortcutsPlugin(): Plugin {
|
||||
return {
|
||||
onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,320 +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 { 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;
|
||||
|
||||
// Commands added to the editor by this plugin.
|
||||
interface SuggestionsPluginCommands {
|
||||
selectSuggestion: (suggestion: CompletionItem) => CoreEditor;
|
||||
applyTypeahead: (suggestion: CompletionItem) => CoreEditor;
|
||||
}
|
||||
|
||||
export interface SuggestionsState {
|
||||
groupedItems: CompletionItemGroup[];
|
||||
typeaheadPrefix: string;
|
||||
typeaheadContext: string;
|
||||
typeaheadText: string;
|
||||
}
|
||||
|
||||
export default function SuggestionsPlugin({
|
||||
onTypeahead,
|
||||
cleanText,
|
||||
onWillApplySuggestion,
|
||||
portalOrigin,
|
||||
}: {
|
||||
onTypeahead: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>;
|
||||
cleanText?: (text: string) => string;
|
||||
onWillApplySuggestion?: (suggestion: string, state: SuggestionsState) => string;
|
||||
portalOrigin: string;
|
||||
}): SlatePlugin {
|
||||
let typeaheadRef: Typeahead;
|
||||
let state: SuggestionsState = {
|
||||
groupedItems: [],
|
||||
typeaheadPrefix: '',
|
||||
typeaheadContext: '',
|
||||
typeaheadText: '',
|
||||
};
|
||||
const handleTypeaheadDebounced = debounce(handleTypeahead, TYPEAHEAD_DEBOUNCE);
|
||||
|
||||
const setState = (update: Partial<SuggestionsState>) => {
|
||||
state = {
|
||||
...state,
|
||||
...update,
|
||||
};
|
||||
};
|
||||
|
||||
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();
|
||||
typeaheadRef.moveMenuIndex(event.key === 'ArrowDown' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
case 'Tab': {
|
||||
if (hasSuggestions) {
|
||||
event.preventDefault();
|
||||
return typeaheadRef.insertSuggestion();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
handleTypeaheadDebounced(editor, setState, onTypeahead, cleanText);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
},
|
||||
|
||||
commands: {
|
||||
selectSuggestion: (editor: CoreEditor, suggestion: CompletionItem): CoreEditor => {
|
||||
const suggestions = state.groupedItems;
|
||||
if (!suggestions || !suggestions.length) {
|
||||
return editor;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const ed = editor.applyTypeahead(suggestion);
|
||||
handleTypeaheadDebounced(editor, setState, onTypeahead, cleanText);
|
||||
return ed;
|
||||
},
|
||||
|
||||
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) => (typeaheadRef = el)}
|
||||
origin={portalOrigin}
|
||||
prefix={state.typeaheadPrefix}
|
||||
isOpen={!!state.groupedItems.length}
|
||||
groupedItems={state.groupedItems}
|
||||
onSelectSuggestion={(editor as CoreEditor & SuggestionsPluginCommands).selectSuggestion}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const handleTypeahead = async (
|
||||
editor: CoreEditor,
|
||||
onStateChange: (state: Partial<SuggestionsState>) => void,
|
||||
onTypeahead?: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>,
|
||||
cleanText?: (text: string) => string
|
||||
): Promise<void> => {
|
||||
if (!onTypeahead) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// 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 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
|
||||
|
||||
onStateChange({
|
||||
groupedItems: filteredSuggestions,
|
||||
typeaheadPrefix: prefix,
|
||||
typeaheadContext: context,
|
||||
typeaheadText: text,
|
||||
});
|
||||
|
||||
// Bogus edit to force re-render
|
||||
editor.blur().focus();
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
// 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
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { GrafanaTheme } from '@grafana/ui';
|
||||
import { default as calculateSize } from 'calculate-size';
|
||||
|
||||
import { CompletionItemGroup, CompletionItem, CompletionItemKind } from 'app/types';
|
||||
|
||||
export const flattenGroupItems = (groupedItems: CompletionItemGroup[]): CompletionItem[] => {
|
||||
return groupedItems.reduce((all, current) => {
|
||||
const titleItem: CompletionItem = {
|
||||
label: current.label,
|
||||
kind: CompletionItemKind.GroupTitle,
|
||||
};
|
||||
return all.concat(titleItem, current.items);
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const calculateLongestLabel = (allItems: CompletionItem[]): string => {
|
||||
return allItems.reduce((longest, current) => {
|
||||
return longest.length < current.label.length ? current.label : longest;
|
||||
}, '');
|
||||
};
|
||||
|
||||
export const calculateListSizes = (theme: GrafanaTheme, allItems: CompletionItem[], longestLabel: string) => {
|
||||
const size = calculateSize(longestLabel, {
|
||||
font: theme.typography.fontFamily.monospace,
|
||||
fontSize: theme.typography.size.sm,
|
||||
fontWeight: 'normal',
|
||||
});
|
||||
|
||||
const listWidth = calculateListWidth(size.width, theme);
|
||||
const itemHeight = calculateItemHeight(size.height, theme);
|
||||
const listHeight = calculateListHeight(itemHeight, allItems);
|
||||
|
||||
return {
|
||||
listWidth,
|
||||
listHeight,
|
||||
itemHeight,
|
||||
};
|
||||
};
|
||||
|
||||
export const calculateItemHeight = (longestLabelHeight: number, theme: GrafanaTheme) => {
|
||||
const horizontalPadding = parseInt(theme.spacing.sm, 10) * 2;
|
||||
const itemHeight = longestLabelHeight + horizontalPadding;
|
||||
|
||||
return itemHeight;
|
||||
};
|
||||
|
||||
export const calculateListWidth = (longestLabelWidth: number, theme: GrafanaTheme) => {
|
||||
const verticalPadding = parseInt(theme.spacing.sm, 10) + parseInt(theme.spacing.md, 10);
|
||||
const maxWidth = 800;
|
||||
const listWidth = Math.min(Math.max(longestLabelWidth + verticalPadding, 200), maxWidth);
|
||||
|
||||
return listWidth;
|
||||
};
|
||||
|
||||
export const calculateListHeight = (itemHeight: number, allItems: CompletionItem[]) => {
|
||||
const numberOfItemsToShow = Math.min(allItems.length, 10);
|
||||
const minHeight = 100;
|
||||
const totalHeight = numberOfItemsToShow * itemHeight;
|
||||
const listHeight = Math.max(totalHeight, minHeight);
|
||||
|
||||
return listHeight;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
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>;
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
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';
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
/* 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;
|
||||
|
||||
Reference in New Issue
Block a user