grafana/public/app/features/explore/QueryField.tsx

554 lines
17 KiB
TypeScript
Raw Normal View History

import _ from 'lodash';
import React, { Context } from 'react';
2018-04-26 04:58:42 -05:00
import ReactDOM from 'react-dom';
// @ts-ignore
import { Change, Value } from 'slate';
// @ts-ignore
2018-04-26 04:58:42 -05:00
import { Editor } from 'slate-react';
// @ts-ignore
2018-04-26 04:58:42 -05:00
import Plain from 'slate-plain-serializer';
import classnames from 'classnames';
2018-04-26 04:58:42 -05:00
import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';
2018-04-26 04:58:42 -05:00
import ClearPlugin from './slate-plugins/clear';
import NewlinePlugin from './slate-plugins/newline';
import { TypeaheadWithTheme } from './Typeahead';
Graph: Add data links feature (click on graph) (#17267) * WIP: initial panel links editor * WIP: Added dashboard migration to new panel drilldown link schema * Make link_srv interpolate new variables * Fix failing tests * Drilldown: Add context menu to graph viz (#17284) * Add simple context menu for adding graph annotations and showing drilldown links * Close graph context menu when user start scrolling * Move context menu component to grafana/ui * Make graph context menu appear on click, use cmd/ctrl click for quick annotations * Move graph context menu controller to separate file * Drilldown: datapoint variables interpolation (#17328) * Add simple context menu for adding graph annotations and showing drilldown links * Close graph context menu when user start scrolling * Move context menu component to grafana/ui * Make graph context menu appear on click, use cmd/ctrl click for quick annotations * Add util for absolute time range transformation * Add series name and datapoint timestamp interpolation * Rename drilldown link variables tot snake case, use const values instead of strings in tests * Bring LinkSrv.getPanelLinkAnchorInfo for compatibility reasons and add deprecation warning * Rename seriesLabel to seriesName * Drilldown: use separate editors for panel and series links (#17355) * Use correct target ini context menu links * Rename PanelLinksEditor to DrilldownLinksEditor and mote it to grafana/ui * Expose DrilldownLinksEditor as an angular directive * Enable visualization specifix drilldown links * Props interfaces rename * Drilldown: Add variables suggestion and syntax highlighting for drilldown link editor (#17391) * Add variables suggestion in drilldown link editor * Enable prism * Fix backspace not working * Move slate value helpers to grafana/ui * Add syntax higlighting for links input * Rename drilldown link components to data links * Add template variabe suggestions * Bugfix * Fix regexp not working in Firefox * Display correct links in panel header corner * bugfix * bugfix * Bugfix * Context menu UI tweaks * Use data link terminology instead of drilldown * DataLinks: changed autocomplete syntax * Use singular form for data link * Use the same syntax higlighting for built-in and template variables in data links editor * UI improvements to context menu * UI review tweaks * Tweak layout of data link editor * Fix vertical spacing * Remove data link header in context menu * Remove pointer cursor from series label in context menu * Fix variable selection on click * DataLinks: migrations for old links * Update docs about data links * Use value time instead of time range when interpolating datapoint timestamp * Remove not used util * Update docs * Moved icon a bit more down * Interpolate value ts only when using __value_time variable * Bring href property back to LinkModel * Add any type annotations * Fix TS error on slate's Value type * minor changes
2019-06-25 04:38:51 -05:00
import { makeFragment, makeValue } from '@grafana/ui';
import PlaceholdersBuffer from './PlaceholdersBuffer';
2018-04-26 04:58:42 -05:00
export const TYPEAHEAD_DEBOUNCE = 100;
export const HIGHLIGHT_WAIT = 500;
2018-04-26 04:58:42 -05:00
function getSuggestionByIndex(suggestions: CompletionItemGroup[], index: number): CompletionItem {
// Flatten suggestion groups
const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []);
const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length;
return flattenedSuggestions[correctedIndex];
}
function hasSuggestions(suggestions: CompletionItemGroup[]): boolean {
return suggestions && suggestions.length > 0;
2018-04-26 04:58:42 -05:00
}
export interface QueryFieldProps {
additionalPlugins?: any[];
cleanText?: (text: string) => string;
disabled?: boolean;
initialQuery: string | null;
onRunQuery?: () => void;
onChange?: (value: string) => void;
onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput;
2018-10-30 08:38:34 -05:00
onWillApplySuggestion?: (suggestion: string, state: QueryFieldState) => string;
placeholder?: string;
portalOrigin?: string;
syntax?: string;
syntaxLoaded?: boolean;
}
2018-04-26 04:58:42 -05:00
2018-10-30 08:38:34 -05:00
export interface QueryFieldState {
suggestions: CompletionItemGroup[];
typeaheadContext: string | null;
typeaheadIndex: number;
typeaheadPrefix: string;
typeaheadText: string;
value: any;
lastExecutedValue: Value;
}
2018-04-26 04:58:42 -05:00
export interface TypeaheadInput {
editorNode: Element;
prefix: string;
selection?: Selection;
text: string;
value: Value;
wrapperNode: Element;
}
/**
* 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.
*/
2018-10-30 08:38:34 -05:00
export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldState> {
menuEl: HTMLElement | null;
placeholdersBuffer: PlaceholdersBuffer;
plugins: any[];
2018-04-26 04:58:42 -05:00
resetTimer: any;
mounted: boolean;
updateHighlightsTimer: any;
2018-04-26 04:58:42 -05:00
constructor(props: QueryFieldProps, context: Context<any>) {
2018-04-26 04:58:42 -05:00
super(props, context);
this.placeholdersBuffer = new PlaceholdersBuffer(props.initialQuery || '');
this.updateHighlightsTimer = _.debounce(this.updateLogsHighlights, HIGHLIGHT_WAIT);
// Base plugins
this.plugins = [ClearPlugin(), NewlinePlugin(), ...(props.additionalPlugins || [])].filter(p => p);
2018-04-26 04:58:42 -05:00
this.state = {
suggestions: [],
typeaheadContext: null,
2018-04-26 04:58:42 -05:00
typeaheadIndex: 0,
typeaheadPrefix: '',
typeaheadText: '',
value: makeValue(this.placeholdersBuffer.toString(), props.syntax),
lastExecutedValue: null,
2018-04-26 04:58:42 -05:00
};
}
componentDidMount() {
this.mounted = true;
2018-04-26 04:58:42 -05:00
this.updateMenu();
}
componentWillUnmount() {
this.mounted = false;
2018-04-26 04:58:42 -05:00
clearTimeout(this.resetTimer);
}
componentDidUpdate(prevProps: QueryFieldProps, prevState: QueryFieldState) {
const { initialQuery, syntax } = this.props;
const { value, suggestions } = this.state;
// if query changed from the outside
if (initialQuery !== prevProps.initialQuery) {
// and we have a version that differs
if (initialQuery !== Plain.serialize(value)) {
this.placeholdersBuffer = new PlaceholdersBuffer(initialQuery || '');
this.setState({ value: makeValue(this.placeholdersBuffer.toString(), syntax) });
}
}
// Only update menu location when suggestion existence or text/selection changed
if (value !== prevState.value || hasSuggestions(suggestions) !== hasSuggestions(prevState.suggestions)) {
this.updateMenu();
}
2018-04-26 04:58:42 -05:00
}
2018-10-30 08:38:34 -05:00
componentWillReceiveProps(nextProps: QueryFieldProps) {
if (nextProps.syntaxLoaded && !this.props.syntaxLoaded) {
// Need a bogus edit to re-render the editor after syntax has fully loaded
const change = this.state.value
.change()
.insertText(' ')
.deleteBackward();
if (this.placeholdersBuffer.hasPlaceholders()) {
change.move(this.placeholdersBuffer.getNextMoveOffset()).focus();
}
2019-01-28 10:41:33 -06:00
this.onChange(change, true);
2018-04-26 04:58:42 -05:00
}
}
onChange = ({ value }: Change, invokeParentOnValueChanged?: boolean) => {
const documentChanged = value.document !== this.state.value.document;
const prevValue = this.state.value;
// Control editor loop, then pass text change up to parent
2018-04-26 04:58:42 -05:00
this.setState({ value }, () => {
if (documentChanged) {
const textChanged = Plain.serialize(prevValue) !== Plain.serialize(value);
2019-01-28 10:41:33 -06:00
if (textChanged && invokeParentOnValueChanged) {
this.executeOnChangeAndRunQueries();
}
if (textChanged && !invokeParentOnValueChanged) {
this.updateHighlightsTimer();
}
2018-04-26 04:58:42 -05:00
}
});
// Show suggest menu on text input
if (documentChanged && value.selection.isCollapsed) {
// Need one paint to allow DOM-based typeahead rules to work
window.requestAnimationFrame(this.handleTypeahead);
} else if (!this.resetTimer) {
this.resetTypeahead();
2018-04-26 04:58:42 -05:00
}
};
updateLogsHighlights = () => {
const { onChange } = this.props;
if (onChange) {
onChange(Plain.serialize(this.state.value));
}
};
executeOnChangeAndRunQueries = () => {
2018-04-26 04:58:42 -05:00
// Send text change to parent
const { onChange, onRunQuery } = this.props;
if (onChange) {
onChange(Plain.serialize(this.state.value));
2019-02-01 04:55:01 -06:00
}
if (onRunQuery) {
onRunQuery();
this.setState({ lastExecutedValue: this.state.value });
2018-04-26 04:58:42 -05:00
}
};
handleTypeahead = _.debounce(async () => {
2018-04-26 04:58:42 -05:00
const selection = window.getSelection();
const { cleanText, onTypeahead } = this.props;
const { value } = this.state;
if (onTypeahead && selection.anchorNode) {
2018-04-26 04:58:42 -05:00
const wrapperNode = selection.anchorNode.parentElement;
2018-06-11 10:36:45 -05:00
const editorNode = wrapperNode.closest('.slate-query-field');
2018-04-26 04:58:42 -05:00
if (!editorNode || this.state.value.isBlurred) {
// Not inside this editor
return;
}
const range = selection.getRangeAt(0);
const offset = range.startOffset;
const text = selection.anchorNode.textContent;
let prefix = text.substr(0, offset);
// Label values could have valid characters erased if `cleanText()` is
// blindly applied, which would undesirably interfere with suggestions
const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/);
if (labelValueMatch) {
prefix = labelValueMatch[1];
} else if (cleanText) {
prefix = cleanText(prefix);
2018-04-26 04:58:42 -05:00
}
const { suggestions, context, refresher } = onTypeahead({
editorNode,
prefix,
selection,
text,
value,
wrapperNode,
2018-04-26 04:58:42 -05:00
});
let filteredSuggestions = suggestions
.map(group => {
if (group.items) {
if (prefix) {
// Filter groups based on prefix
if (!group.skipFilter) {
group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length);
if (group.prefixMatch) {
group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) === 0);
} else {
group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) > -1);
}
}
// Filter out the already typed value (prefix) unless it inserts custom text
group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix);
}
2018-04-26 04:58:42 -05:00
if (!group.skipSort) {
group.items = _.sortBy(group.items, (item: CompletionItem) => item.sortText || item.label);
}
}
return group;
})
.filter(group => group.items && group.items.length > 0); // Filter out empty groups
2018-04-26 04:58:42 -05:00
// Keep same object for equality checking later
if (_.isEqual(filteredSuggestions, this.state.suggestions)) {
filteredSuggestions = this.state.suggestions;
}
this.setState(
{
suggestions: filteredSuggestions,
typeaheadPrefix: prefix,
typeaheadContext: context,
typeaheadText: text,
},
() => {
if (refresher) {
refresher.then(this.handleTypeahead).catch(e => console.error(e));
}
2018-04-26 04:58:42 -05:00
}
);
}
}, TYPEAHEAD_DEBOUNCE);
2018-04-26 04:58:42 -05:00
applyTypeahead(change: Change, suggestion: CompletionItem): Change {
const { cleanText, onWillApplySuggestion, syntax } = this.props;
const { typeaheadPrefix, typeaheadText } = this.state;
let suggestionText = suggestion.insertText || suggestion.label;
const preserveSuffix = suggestion.kind === 'function';
const move = suggestion.move || 0;
2018-04-26 04:58:42 -05:00
if (onWillApplySuggestion) {
suggestionText = onWillApplySuggestion(suggestionText, { ...this.state });
2018-04-26 04:58:42 -05:00
}
this.resetTypeahead();
// Remove the current, incomplete text and replace it with the selected suggestion
const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
const text = cleanText ? cleanText(typeaheadText) : typeaheadText;
2018-04-26 04:58:42 -05:00
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;
2018-04-26 04:58:42 -05:00
// If new-lines, apply suggestion as block
if (suggestionText.match(/\n/)) {
const fragment = makeFragment(suggestionText, syntax);
return change
2018-04-26 04:58:42 -05:00
.deleteBackward(backward)
.deleteForward(forward)
.insertFragment(fragment)
.focus();
}
return change
.deleteBackward(backward)
.deleteForward(forward)
.insertText(suggestionText)
.move(move)
.focus();
2018-04-26 04:58:42 -05:00
}
handleEnterAndTabKey = (event: KeyboardEvent, change: Change) => {
2018-06-12 05:31:21 -05:00
const { typeaheadIndex, suggestions } = this.state;
2019-01-28 10:41:33 -06:00
if (this.menuEl) {
// Dont blur input
event.preventDefault();
if (!suggestions || suggestions.length === 0) {
return undefined;
}
const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex);
const nextChange = this.applyTypeahead(change, suggestion);
const insertTextOperation = nextChange.operations.find((operation: any) => operation.type === 'insert_text');
2019-01-28 10:41:33 -06:00
if (insertTextOperation) {
const suggestionText = insertTextOperation.text;
this.placeholdersBuffer.setNextPlaceholderValue(suggestionText);
if (this.placeholdersBuffer.hasPlaceholders()) {
nextChange.move(this.placeholdersBuffer.getNextMoveOffset()).focus();
}
}
return true;
} else if (!event.shiftKey) {
// Run queries if Shift is not pressed, otherwise pass through
this.executeOnChangeAndRunQueries();
2019-01-28 10:41:33 -06:00
return true;
2019-01-28 10:41:33 -06:00
}
return undefined;
2019-01-28 10:41:33 -06:00
};
onKeyDown = (event: KeyboardEvent, change: Change) => {
2019-01-28 10:41:33 -06:00
const { typeaheadIndex } = this.state;
2018-06-12 05:31:21 -05:00
switch (event.key) {
case 'Escape': {
if (this.menuEl) {
event.preventDefault();
event.stopPropagation();
this.resetTypeahead();
return true;
}
break;
}
case ' ': {
if (event.ctrlKey) {
event.preventDefault();
this.handleTypeahead();
return true;
2018-04-26 04:58:42 -05:00
}
2018-06-12 05:31:21 -05:00
break;
}
case 'Enter':
2018-06-12 05:31:21 -05:00
case 'Tab': {
return this.handleEnterAndTabKey(event, change);
2018-06-12 05:31:21 -05:00
break;
}
2018-04-26 04:58:42 -05:00
2018-06-12 05:31:21 -05:00
case 'ArrowDown': {
if (this.menuEl) {
2018-04-26 04:58:42 -05:00
// Select next suggestion
event.preventDefault();
const itemsCount =
this.state.suggestions.length > 0
? this.state.suggestions.reduce((totalCount, current) => totalCount + current.items.length, 0)
: 0;
this.setState({ typeaheadIndex: Math.min(itemsCount - 1, typeaheadIndex + 1) });
2018-04-26 04:58:42 -05:00
}
2018-06-12 05:31:21 -05:00
break;
}
2018-04-26 04:58:42 -05:00
2018-06-12 05:31:21 -05:00
case 'ArrowUp': {
if (this.menuEl) {
2018-04-26 04:58:42 -05:00
// Select previous suggestion
event.preventDefault();
this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) });
}
2018-06-12 05:31:21 -05:00
break;
}
2018-04-26 04:58:42 -05:00
2018-06-12 05:31:21 -05:00
default: {
// console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
break;
2018-04-26 04:58:42 -05:00
}
}
return undefined;
};
resetTypeahead = () => {
if (this.mounted) {
2019-01-28 10:41:33 -06:00
this.setState({ suggestions: [], typeaheadIndex: 0, typeaheadPrefix: '', typeaheadContext: null });
this.resetTimer = null;
}
2018-04-26 04:58:42 -05:00
};
handleBlur = (event: FocusEvent, change: Change) => {
const { lastExecutedValue } = this.state;
const previousValue = lastExecutedValue ? Plain.serialize(this.state.lastExecutedValue) : null;
const currentValue = Plain.serialize(change.value);
2018-04-26 04:58:42 -05:00
// If we dont wait here, menu clicks wont work because the menu
// will be gone.
this.resetTimer = setTimeout(this.resetTypeahead, 100);
// Disrupting placeholder entry wipes all remaining placeholders needing input
this.placeholdersBuffer.clearPlaceholders();
if (previousValue !== currentValue) {
this.executeOnChangeAndRunQueries();
}
2018-04-26 04:58:42 -05:00
};
onClickMenu = (item: CompletionItem) => {
2018-04-26 04:58:42 -05:00
// Manually triggering change
const change = this.applyTypeahead(this.state.value.change(), item);
2019-01-28 10:41:33 -06:00
this.onChange(change, true);
2018-04-26 04:58:42 -05:00
};
updateMenu = () => {
const { suggestions } = this.state;
const menu = this.menuEl;
const selection = window.getSelection();
const node = selection.anchorNode;
// No menu, nothing to do
if (!menu) {
return;
}
// No suggestions or blur, remove menu
if (!hasSuggestions(suggestions)) {
2018-04-26 04:58:42 -05:00
menu.removeAttribute('style');
return;
}
// Align menu overlay to editor node
if (node) {
// Read from DOM
2018-04-26 04:58:42 -05:00
const rect = node.parentElement.getBoundingClientRect();
const scrollX = window.scrollX;
const scrollY = window.scrollY;
// Write DOM
requestAnimationFrame(() => {
menu.style.opacity = '1';
menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
menu.style.left = `${rect.left + scrollX - 2}px`;
});
2018-04-26 04:58:42 -05:00
}
};
menuRef = (el: HTMLElement) => {
2018-04-26 04:58:42 -05:00
this.menuEl = el;
};
renderMenu = () => {
const { portalOrigin } = this.props;
const { suggestions, typeaheadIndex, typeaheadPrefix } = this.state;
if (!hasSuggestions(suggestions)) {
2018-04-26 04:58:42 -05:00
return null;
}
const selectedItem = getSuggestionByIndex(suggestions, typeaheadIndex);
2018-04-26 04:58:42 -05:00
// Create typeahead in DOM root so we can later position it absolutely
return (
<Portal origin={portalOrigin}>
<TypeaheadWithTheme
2018-04-26 04:58:42 -05:00
menuRef={this.menuRef}
selectedItem={selectedItem}
onClickItem={this.onClickMenu}
prefix={typeaheadPrefix}
2018-04-26 04:58:42 -05:00
groupedItems={suggestions}
typeaheadIndex={typeaheadIndex}
2018-04-26 04:58:42 -05:00
/>
</Portal>
);
};
handlePaste = (event: ClipboardEvent, change: Editor) => {
const pastedValue = event.clipboardData.getData('Text');
const newValue = change.value.change().insertText(pastedValue);
this.onChange(newValue);
return true;
};
2018-04-26 04:58:42 -05:00
render() {
const { disabled } = this.props;
const wrapperClassName = classnames('slate-query-field__wrapper', {
'slate-query-field__wrapper--disabled': disabled,
});
2018-04-26 04:58:42 -05:00
return (
<div className={wrapperClassName}>
<div className="slate-query-field">
{this.renderMenu()}
<Editor
autoCorrect={false}
readOnly={this.props.disabled}
onBlur={this.handleBlur}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
onPaste={this.handlePaste}
placeholder={this.props.placeholder}
plugins={this.plugins}
spellCheck={false}
value={this.state.value}
/>
</div>
2018-04-26 04:58:42 -05:00
</div>
);
}
}
interface PortalProps {
index?: number;
origin: string;
}
class Portal extends React.PureComponent<PortalProps, {}> {
node: HTMLElement;
constructor(props: PortalProps) {
super(props);
const { index = 0, origin = 'query' } = props;
this.node = document.createElement('div');
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${origin}-${index}`);
document.body.appendChild(this.node);
}
componentWillUnmount() {
document.body.removeChild(this.node);
}
render() {
return ReactDOM.createPortal(this.props.children, this.node);
}
}
2018-04-26 04:58:42 -05:00
export default QueryField;