Files
grafana/packages/grafana-ui/src/components/QueryField/QueryField.tsx

255 lines
7.8 KiB
TypeScript
Raw Normal View History

import { css, cx } from '@emotion/css';
import classnames from 'classnames';
import { debounce } from 'lodash';
import React, { Context } from 'react';
import { Value, Editor as CoreEditor } from 'slate';
2018-04-26 11:58:42 +02:00
import Plain from 'slate-plain-serializer';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Editor, Plugin } from '@grafana/slate-react';
import {
makeValue,
SCHEMA,
CompletionItemGroup,
TypeaheadOutput,
TypeaheadInput,
SuggestionsState,
Themeable2,
} from '../..';
import {
ClearPlugin,
NewlinePlugin,
SelectionShortcutsPlugin,
IndentationPlugin,
ClipboardPlugin,
RunnerPlugin,
SuggestionsPlugin,
} from '../../slate-plugins';
import { withTheme2 } from '../../themes';
import { getFocusStyles } from '../../themes/mixins';
export interface QueryFieldProps extends Themeable2 {
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;
onBlur?: () => void;
onChange?: (value: string) => void;
onRichValueChange?: (value: Value) => void;
onClick?: (event: Event, editor: CoreEditor, next: () => any) => any;
onTypeahead?: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>;
onWillApplySuggestion?: (suggestion: string, state: SuggestionsState) => string;
placeholder?: string;
portalOrigin: string;
syntax?: string;
syntaxLoaded?: boolean;
theme: GrafanaTheme2;
}
2018-04-26 11:58:42 +02:00
2018-10-30 14:38:34 +01:00
export interface QueryFieldState {
suggestions: CompletionItemGroup[];
typeaheadContext: string | null;
typeaheadPrefix: string;
typeaheadText: string;
value: Value;
}
2018-04-26 11:58:42 +02:00
/**
* 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 UnThemedQueryField extends React.PureComponent<QueryFieldProps, QueryFieldState> {
plugins: Plugin[];
runOnChangeDebounced: Function;
lastExecutedValue: Value | null = null;
mounted = false;
editor: Editor | null = null;
2018-04-26 11:58:42 +02:00
constructor(props: QueryFieldProps, context: Context<any>) {
2018-04-26 11:58:42 +02:00
super(props, context);
this.runOnChangeDebounced = debounce(this.runOnChange, 500);
const { onTypeahead, cleanText, portalOrigin, onWillApplySuggestion } = props;
// Base plugins
this.plugins = [
// SuggestionsPlugin and RunnerPlugin need to be before NewlinePlugin
// because they override Enter behavior
SuggestionsPlugin({ onTypeahead, cleanText, portalOrigin, onWillApplySuggestion }),
RunnerPlugin({ handler: this.runOnChangeAndRunQuery }),
NewlinePlugin(),
ClearPlugin(),
SelectionShortcutsPlugin(),
IndentationPlugin(),
ClipboardPlugin(),
...(props.additionalPlugins || []),
].filter((p) => p);
2018-04-26 11:58:42 +02:00
this.state = {
suggestions: [],
typeaheadContext: null,
2018-04-26 11:58:42 +02:00
typeaheadPrefix: '',
typeaheadText: '',
value: makeValue(props.query || '', props.syntax),
2018-04-26 11:58:42 +02:00
};
}
componentDidMount() {
this.mounted = true;
2018-04-26 11:58:42 +02:00
}
componentWillUnmount() {
this.mounted = false;
2018-04-26 11:58:42 +02:00
}
componentDidUpdate(prevProps: QueryFieldProps, prevState: QueryFieldState) {
const { query, syntax, syntaxLoaded } = this.props;
if (!prevProps.syntaxLoaded && syntaxLoaded && this.editor) {
// Need a bogus edit to re-render the editor after syntax has fully loaded
const editor = this.editor.insertText(' ').deleteBackward(1);
this.onChange(editor.value, true);
}
const { 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) });
}
}
2018-04-26 11:58:42 +02:00
}
/**
* 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;
if (this.props.onRichValueChange) {
this.props.onRichValueChange(value);
}
// Update local state with new value and optionally change value upstream.
2018-04-26 11:58:42 +02:00
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();
}
2018-04-26 11:58:42 +02:00
}
});
};
runOnChange = () => {
const { onChange } = this.props;
const value = Plain.serialize(this.state.value);
if (onChange) {
onChange(this.cleanText(value));
}
};
runOnRunQuery = () => {
const { onRunQuery } = this.props;
2019-02-01 11:55:01 +01:00
if (onRunQuery) {
onRunQuery();
this.lastExecutedValue = this.state.value;
2018-04-26 11:58:42 +02:00
}
};
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 { onBlur } = this.props;
if (onBlur) {
onBlur();
} else {
// Run query by default on blur
const previousValue = this.lastExecutedValue ? Plain.serialize(this.lastExecutedValue) : null;
const currentValue = Plain.serialize(editor.value);
if (previousValue !== currentValue) {
this.runOnChangeAndRunQuery();
}
}
return next();
};
cleanText(text: string) {
// RegExp with invisible characters we want to remove - currently only carriage return (newlines are visible)
const newText = text.replace(/[\r]/g, '');
return newText;
}
2018-04-26 11:58:42 +02:00
render() {
const { disabled, theme } = this.props;
const wrapperClassName = classnames('slate-query-field__wrapper', {
'slate-query-field__wrapper--disabled': disabled,
});
const styles = getStyles(theme);
2018-04-26 11:58:42 +02:00
return (
<div className={cx(wrapperClassName, styles.wrapper)}>
<div className="slate-query-field" aria-label={selectors.components.QueryField.container}>
<Editor
ref={(editor) => (this.editor = editor!)}
schema={SCHEMA}
autoCorrect={false}
readOnly={this.props.disabled}
onBlur={this.handleBlur}
onClick={this.props.onClick}
// 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>
2018-04-26 11:58:42 +02:00
</div>
);
}
}
export const QueryField = withTheme2(UnThemedQueryField);
const getStyles = (theme: GrafanaTheme2) => {
const focusStyles = getFocusStyles(theme);
return {
wrapper: css`
&:focus-within {
${focusStyles}
}
`,
};
};