|
@@ -1,106 +1,163 @@
|
|
|
|
|
+import _ from 'lodash';
|
|
|
import React from 'react';
|
|
import React from 'react';
|
|
|
import ReactDOM from 'react-dom';
|
|
import ReactDOM from 'react-dom';
|
|
|
-import { Value } from 'slate';
|
|
|
|
|
|
|
+import { Block, Change, Document, Text, Value } from 'slate';
|
|
|
import { Editor } from 'slate-react';
|
|
import { Editor } from 'slate-react';
|
|
|
import Plain from 'slate-plain-serializer';
|
|
import Plain from 'slate-plain-serializer';
|
|
|
|
|
|
|
|
-// dom also includes Element polyfills
|
|
|
|
|
-import { getNextCharacter, getPreviousCousin } from './utils/dom';
|
|
|
|
|
import BracesPlugin from './slate-plugins/braces';
|
|
import BracesPlugin from './slate-plugins/braces';
|
|
|
import ClearPlugin from './slate-plugins/clear';
|
|
import ClearPlugin from './slate-plugins/clear';
|
|
|
import NewlinePlugin from './slate-plugins/newline';
|
|
import NewlinePlugin from './slate-plugins/newline';
|
|
|
-import PluginPrism, { setPrismTokens } from './slate-plugins/prism/index';
|
|
|
|
|
-import RunnerPlugin from './slate-plugins/runner';
|
|
|
|
|
-import debounce from './utils/debounce';
|
|
|
|
|
-import { processLabels, RATE_RANGES, cleanText } from './utils/prometheus';
|
|
|
|
|
|
|
|
|
|
import Typeahead from './Typeahead';
|
|
import Typeahead from './Typeahead';
|
|
|
|
|
|
|
|
-const EMPTY_METRIC = '';
|
|
|
|
|
-const METRIC_MARK = 'metric';
|
|
|
|
|
export const TYPEAHEAD_DEBOUNCE = 300;
|
|
export const TYPEAHEAD_DEBOUNCE = 300;
|
|
|
|
|
|
|
|
-function flattenSuggestions(s) {
|
|
|
|
|
|
|
+function flattenSuggestions(s: any[]): any[] {
|
|
|
return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
|
|
return s ? s.reduce((acc, g) => acc.concat(g.items), []) : [];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-export const getInitialValue = query =>
|
|
|
|
|
- Value.fromJSON({
|
|
|
|
|
- document: {
|
|
|
|
|
- nodes: [
|
|
|
|
|
- {
|
|
|
|
|
- object: 'block',
|
|
|
|
|
- type: 'paragraph',
|
|
|
|
|
- nodes: [
|
|
|
|
|
- {
|
|
|
|
|
- object: 'text',
|
|
|
|
|
- leaves: [
|
|
|
|
|
- {
|
|
|
|
|
- text: query,
|
|
|
|
|
- },
|
|
|
|
|
- ],
|
|
|
|
|
- },
|
|
|
|
|
- ],
|
|
|
|
|
- },
|
|
|
|
|
- ],
|
|
|
|
|
- },
|
|
|
|
|
|
|
+export const makeFragment = (text: string): Document => {
|
|
|
|
|
+ const lines = text.split('\n').map(line =>
|
|
|
|
|
+ Block.create({
|
|
|
|
|
+ type: 'paragraph',
|
|
|
|
|
+ nodes: [Text.create(line)],
|
|
|
|
|
+ })
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const fragment = Document.create({
|
|
|
|
|
+ nodes: lines,
|
|
|
});
|
|
});
|
|
|
|
|
+ return fragment;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+export const getInitialValue = (value: string): Value => Value.create({ document: makeFragment(value) });
|
|
|
|
|
+
|
|
|
|
|
+export interface Suggestion {
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 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. Based on the kind
|
|
|
|
|
+ * an icon is chosen by the editor.
|
|
|
|
|
+ */
|
|
|
|
|
+ kind?: string;
|
|
|
|
|
+ /**
|
|
|
|
|
+ * A human-readable string with additional information
|
|
|
|
|
+ * about this item, like type or symbol information.
|
|
|
|
|
+ */
|
|
|
|
|
+ detail?: string;
|
|
|
|
|
+ /**
|
|
|
|
|
+ * A human-readable string, can be Markdown, that represents a doc-comment.
|
|
|
|
|
+ */
|
|
|
|
|
+ documentation?: string;
|
|
|
|
|
+ /**
|
|
|
|
|
+ * A string that should be used when comparing this item
|
|
|
|
|
+ * with other items. When `falsy` the `label` is used.
|
|
|
|
|
+ */
|
|
|
|
|
+ sortText?: string;
|
|
|
|
|
+ /**
|
|
|
|
|
+ * A string that should be used when filtering a set of
|
|
|
|
|
+ * completion items. When `falsy` the `label` is used.
|
|
|
|
|
+ */
|
|
|
|
|
+ filterText?: string;
|
|
|
|
|
+ /**
|
|
|
|
|
+ * A string or snippet that should be inserted in a document when selecting
|
|
|
|
|
+ * this completion. When `falsy` the `label` is used.
|
|
|
|
|
+ */
|
|
|
|
|
+ insertText?: string;
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Delete number of characters before the caret position,
|
|
|
|
|
+ * by default the letters from the beginning of the word.
|
|
|
|
|
+ */
|
|
|
|
|
+ deleteBackwards?: number;
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Number of steps to move after the insertion, can be negative.
|
|
|
|
|
+ */
|
|
|
|
|
+ move?: number;
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-class Portal extends React.Component<any, any> {
|
|
|
|
|
- node: any;
|
|
|
|
|
|
|
+export interface SuggestionGroup {
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Label that will be displayed for all entries of this group.
|
|
|
|
|
+ */
|
|
|
|
|
+ label: string;
|
|
|
|
|
+ /**
|
|
|
|
|
+ * List of suggestions of this group.
|
|
|
|
|
+ */
|
|
|
|
|
+ items: Suggestion[];
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 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;
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- constructor(props) {
|
|
|
|
|
- super(props);
|
|
|
|
|
- const { index = 0, prefix = 'query' } = props;
|
|
|
|
|
- this.node = document.createElement('div');
|
|
|
|
|
- this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
|
|
|
|
|
- document.body.appendChild(this.node);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+interface TypeaheadFieldProps {
|
|
|
|
|
+ additionalPlugins?: any[];
|
|
|
|
|
+ cleanText?: (text: string) => string;
|
|
|
|
|
+ initialValue: string | null;
|
|
|
|
|
+ onBlur?: () => void;
|
|
|
|
|
+ onFocus?: () => void;
|
|
|
|
|
+ onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput;
|
|
|
|
|
+ onValueChanged?: (value: Value) => void;
|
|
|
|
|
+ onWillApplySuggestion?: (suggestion: string, state: TypeaheadFieldState) => string;
|
|
|
|
|
+ placeholder?: string;
|
|
|
|
|
+ portalPrefix?: string;
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- componentWillUnmount() {
|
|
|
|
|
- document.body.removeChild(this.node);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+export interface TypeaheadFieldState {
|
|
|
|
|
+ suggestions: SuggestionGroup[];
|
|
|
|
|
+ typeaheadContext: string | null;
|
|
|
|
|
+ typeaheadIndex: number;
|
|
|
|
|
+ typeaheadPrefix: string;
|
|
|
|
|
+ typeaheadText: string;
|
|
|
|
|
+ value: Value;
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
- render() {
|
|
|
|
|
- return ReactDOM.createPortal(this.props.children, this.node);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+export interface TypeaheadInput {
|
|
|
|
|
+ editorNode: Element;
|
|
|
|
|
+ prefix: string;
|
|
|
|
|
+ selection?: Selection;
|
|
|
|
|
+ text: string;
|
|
|
|
|
+ wrapperNode: Element;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export interface TypeaheadOutput {
|
|
|
|
|
+ context?: string;
|
|
|
|
|
+ refresher?: Promise<{}>;
|
|
|
|
|
+ suggestions: SuggestionGroup[];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-class QueryField extends React.Component<any, any> {
|
|
|
|
|
- menuEl: any;
|
|
|
|
|
- plugins: any;
|
|
|
|
|
|
|
+class QueryField extends React.Component<TypeaheadFieldProps, TypeaheadFieldState> {
|
|
|
|
|
+ menuEl: HTMLElement | null;
|
|
|
|
|
+ plugins: any[];
|
|
|
resetTimer: any;
|
|
resetTimer: any;
|
|
|
|
|
|
|
|
constructor(props, context) {
|
|
constructor(props, context) {
|
|
|
super(props, context);
|
|
super(props, context);
|
|
|
|
|
|
|
|
- const { prismDefinition = {}, prismLanguage = 'promql' } = props;
|
|
|
|
|
-
|
|
|
|
|
- this.plugins = [
|
|
|
|
|
- BracesPlugin(),
|
|
|
|
|
- ClearPlugin(),
|
|
|
|
|
- RunnerPlugin({ handler: props.onPressEnter }),
|
|
|
|
|
- NewlinePlugin(),
|
|
|
|
|
- PluginPrism({ definition: prismDefinition, language: prismLanguage }),
|
|
|
|
|
- ];
|
|
|
|
|
|
|
+ // Base plugins
|
|
|
|
|
+ this.plugins = [BracesPlugin(), ClearPlugin(), NewlinePlugin(), ...props.additionalPlugins];
|
|
|
|
|
|
|
|
this.state = {
|
|
this.state = {
|
|
|
- labelKeys: {},
|
|
|
|
|
- labelValues: {},
|
|
|
|
|
- metrics: props.metrics || [],
|
|
|
|
|
suggestions: [],
|
|
suggestions: [],
|
|
|
|
|
+ typeaheadContext: null,
|
|
|
typeaheadIndex: 0,
|
|
typeaheadIndex: 0,
|
|
|
typeaheadPrefix: '',
|
|
typeaheadPrefix: '',
|
|
|
- value: getInitialValue(props.initialQuery || ''),
|
|
|
|
|
|
|
+ typeaheadText: '',
|
|
|
|
|
+ value: getInitialValue(props.initialValue || ''),
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
componentDidMount() {
|
|
componentDidMount() {
|
|
|
this.updateMenu();
|
|
this.updateMenu();
|
|
|
-
|
|
|
|
|
- if (this.props.metrics === undefined) {
|
|
|
|
|
- this.fetchMetricNames();
|
|
|
|
|
- }
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
componentWillUnmount() {
|
|
componentWillUnmount() {
|
|
@@ -112,12 +169,9 @@ class QueryField extends React.Component<any, any> {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
componentWillReceiveProps(nextProps) {
|
|
componentWillReceiveProps(nextProps) {
|
|
|
- if (nextProps.metrics && nextProps.metrics !== this.props.metrics) {
|
|
|
|
|
- this.setState({ metrics: nextProps.metrics }, this.onMetricsReceived);
|
|
|
|
|
- }
|
|
|
|
|
- // initialQuery is null in case the user typed
|
|
|
|
|
- if (nextProps.initialQuery !== null && nextProps.initialQuery !== this.props.initialQuery) {
|
|
|
|
|
- this.setState({ value: getInitialValue(nextProps.initialQuery) });
|
|
|
|
|
|
|
+ // initialValue is null in case the user typed
|
|
|
|
|
+ if (nextProps.initialValue !== null && nextProps.initialValue !== this.props.initialValue) {
|
|
|
|
|
+ this.setState({ value: getInitialValue(nextProps.initialValue) });
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -125,48 +179,28 @@ class QueryField extends React.Component<any, any> {
|
|
|
const changed = value.document !== this.state.value.document;
|
|
const changed = value.document !== this.state.value.document;
|
|
|
this.setState({ value }, () => {
|
|
this.setState({ value }, () => {
|
|
|
if (changed) {
|
|
if (changed) {
|
|
|
- this.handleChangeQuery();
|
|
|
|
|
|
|
+ this.handleChangeValue();
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- window.requestAnimationFrame(this.handleTypeahead);
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- onMetricsReceived = () => {
|
|
|
|
|
- if (!this.state.metrics) {
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- setPrismTokens(this.props.prismLanguage, METRIC_MARK, this.state.metrics);
|
|
|
|
|
-
|
|
|
|
|
- // Trigger re-render
|
|
|
|
|
- window.requestAnimationFrame(() => {
|
|
|
|
|
- // Bogus edit to trigger highlighting
|
|
|
|
|
- const change = this.state.value
|
|
|
|
|
- .change()
|
|
|
|
|
- .insertText(' ')
|
|
|
|
|
- .deleteBackward(1);
|
|
|
|
|
- this.onChange(change);
|
|
|
|
|
- });
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- request = url => {
|
|
|
|
|
- if (this.props.request) {
|
|
|
|
|
- return this.props.request(url);
|
|
|
|
|
|
|
+ if (changed) {
|
|
|
|
|
+ window.requestAnimationFrame(this.handleTypeahead);
|
|
|
}
|
|
}
|
|
|
- return fetch(url);
|
|
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- handleChangeQuery = () => {
|
|
|
|
|
|
|
+ handleChangeValue = () => {
|
|
|
// Send text change to parent
|
|
// Send text change to parent
|
|
|
- const { onQueryChange } = this.props;
|
|
|
|
|
- if (onQueryChange) {
|
|
|
|
|
- onQueryChange(Plain.serialize(this.state.value));
|
|
|
|
|
|
|
+ const { onValueChanged } = this.props;
|
|
|
|
|
+ if (onValueChanged) {
|
|
|
|
|
+ onValueChanged(Plain.serialize(this.state.value));
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- handleTypeahead = debounce(() => {
|
|
|
|
|
|
|
+ handleTypeahead = _.debounce(async () => {
|
|
|
const selection = window.getSelection();
|
|
const selection = window.getSelection();
|
|
|
- if (selection.anchorNode) {
|
|
|
|
|
|
|
+ const { cleanText, onTypeahead } = this.props;
|
|
|
|
|
+
|
|
|
|
|
+ if (onTypeahead && selection.anchorNode) {
|
|
|
const wrapperNode = selection.anchorNode.parentElement;
|
|
const wrapperNode = selection.anchorNode.parentElement;
|
|
|
const editorNode = wrapperNode.closest('.slate-query-field');
|
|
const editorNode = wrapperNode.closest('.slate-query-field');
|
|
|
if (!editorNode || this.state.value.isBlurred) {
|
|
if (!editorNode || this.state.value.isBlurred) {
|
|
@@ -175,164 +209,96 @@ class QueryField extends React.Component<any, any> {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const range = selection.getRangeAt(0);
|
|
const range = selection.getRangeAt(0);
|
|
|
- const text = selection.anchorNode.textContent;
|
|
|
|
|
const offset = range.startOffset;
|
|
const offset = range.startOffset;
|
|
|
- const prefix = cleanText(text.substr(0, offset));
|
|
|
|
|
-
|
|
|
|
|
- // Determine candidates by context
|
|
|
|
|
- const suggestionGroups = [];
|
|
|
|
|
- const wrapperClasses = wrapperNode.classList;
|
|
|
|
|
- let typeaheadContext = null;
|
|
|
|
|
-
|
|
|
|
|
- // Take first metric as lucky guess
|
|
|
|
|
- const metricNode = editorNode.querySelector(`.${METRIC_MARK}`);
|
|
|
|
|
-
|
|
|
|
|
- if (wrapperClasses.contains('context-range')) {
|
|
|
|
|
- // Rate ranges
|
|
|
|
|
- typeaheadContext = 'context-range';
|
|
|
|
|
- suggestionGroups.push({
|
|
|
|
|
- label: 'Range vector',
|
|
|
|
|
- items: [...RATE_RANGES],
|
|
|
|
|
- });
|
|
|
|
|
- } else if (wrapperClasses.contains('context-labels') && metricNode) {
|
|
|
|
|
- const metric = metricNode.textContent;
|
|
|
|
|
- const labelKeys = this.state.labelKeys[metric];
|
|
|
|
|
- if (labelKeys) {
|
|
|
|
|
- if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
|
|
|
|
|
- // Label values
|
|
|
|
|
- const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
|
|
|
|
|
- if (labelKeyNode) {
|
|
|
|
|
- const labelKey = labelKeyNode.textContent;
|
|
|
|
|
- const labelValues = this.state.labelValues[metric][labelKey];
|
|
|
|
|
- typeaheadContext = 'context-label-values';
|
|
|
|
|
- suggestionGroups.push({
|
|
|
|
|
- label: 'Label values',
|
|
|
|
|
- items: labelValues,
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
- } else {
|
|
|
|
|
- // Label keys
|
|
|
|
|
- typeaheadContext = 'context-labels';
|
|
|
|
|
- suggestionGroups.push({ label: 'Labels', items: labelKeys });
|
|
|
|
|
- }
|
|
|
|
|
- } else {
|
|
|
|
|
- this.fetchMetricLabels(metric);
|
|
|
|
|
- }
|
|
|
|
|
- } else if (wrapperClasses.contains('context-labels') && !metricNode) {
|
|
|
|
|
- // Empty name queries
|
|
|
|
|
- const defaultKeys = ['job', 'instance'];
|
|
|
|
|
- // Munge all keys that we have seen together
|
|
|
|
|
- const labelKeys = Object.keys(this.state.labelKeys).reduce((acc, metric) => {
|
|
|
|
|
- return acc.concat(this.state.labelKeys[metric].filter(key => acc.indexOf(key) === -1));
|
|
|
|
|
- }, defaultKeys);
|
|
|
|
|
- if ((text && text.startsWith('=')) || wrapperClasses.contains('attr-value')) {
|
|
|
|
|
- // Label values
|
|
|
|
|
- const labelKeyNode = getPreviousCousin(wrapperNode, '.attr-name');
|
|
|
|
|
- if (labelKeyNode) {
|
|
|
|
|
- const labelKey = labelKeyNode.textContent;
|
|
|
|
|
- if (this.state.labelValues[EMPTY_METRIC]) {
|
|
|
|
|
- const labelValues = this.state.labelValues[EMPTY_METRIC][labelKey];
|
|
|
|
|
- typeaheadContext = 'context-label-values';
|
|
|
|
|
- suggestionGroups.push({
|
|
|
|
|
- label: 'Label values',
|
|
|
|
|
- items: labelValues,
|
|
|
|
|
- });
|
|
|
|
|
- } else {
|
|
|
|
|
- // Can only query label values for now (API to query keys is under development)
|
|
|
|
|
- this.fetchLabelValues(labelKey);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- } else {
|
|
|
|
|
- // Label keys
|
|
|
|
|
- typeaheadContext = 'context-labels';
|
|
|
|
|
- suggestionGroups.push({ label: 'Labels', items: labelKeys });
|
|
|
|
|
- }
|
|
|
|
|
- } else if (metricNode && wrapperClasses.contains('context-aggregation')) {
|
|
|
|
|
- typeaheadContext = 'context-aggregation';
|
|
|
|
|
- const metric = metricNode.textContent;
|
|
|
|
|
- const labelKeys = this.state.labelKeys[metric];
|
|
|
|
|
- if (labelKeys) {
|
|
|
|
|
- suggestionGroups.push({ label: 'Labels', items: labelKeys });
|
|
|
|
|
- } else {
|
|
|
|
|
- this.fetchMetricLabels(metric);
|
|
|
|
|
- }
|
|
|
|
|
- } else if (
|
|
|
|
|
- (this.state.metrics && ((prefix && !wrapperClasses.contains('token')) || text.match(/[+\-*/^%]/))) ||
|
|
|
|
|
- wrapperClasses.contains('context-function')
|
|
|
|
|
- ) {
|
|
|
|
|
- // Need prefix for metrics
|
|
|
|
|
- typeaheadContext = 'context-metrics';
|
|
|
|
|
- suggestionGroups.push({
|
|
|
|
|
- label: 'Metrics',
|
|
|
|
|
- items: this.state.metrics,
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ const text = selection.anchorNode.textContent;
|
|
|
|
|
+ let prefix = text.substr(0, offset);
|
|
|
|
|
+ if (cleanText) {
|
|
|
|
|
+ prefix = cleanText(prefix);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- let results = 0;
|
|
|
|
|
- const filteredSuggestions = suggestionGroups.map(group => {
|
|
|
|
|
- if (group.items) {
|
|
|
|
|
- group.items = group.items.filter(c => c.length !== prefix.length && c.indexOf(prefix) > -1);
|
|
|
|
|
- results += group.items.length;
|
|
|
|
|
- }
|
|
|
|
|
- return group;
|
|
|
|
|
|
|
+ const { suggestions, context, refresher } = onTypeahead({
|
|
|
|
|
+ editorNode,
|
|
|
|
|
+ prefix,
|
|
|
|
|
+ selection,
|
|
|
|
|
+ text,
|
|
|
|
|
+ wrapperNode,
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- console.log('handleTypeahead', selection.anchorNode, wrapperClasses, text, offset, prefix, typeaheadContext);
|
|
|
|
|
-
|
|
|
|
|
- this.setState({
|
|
|
|
|
- typeaheadPrefix: prefix,
|
|
|
|
|
- typeaheadContext,
|
|
|
|
|
- typeaheadText: text,
|
|
|
|
|
- suggestions: results > 0 ? filteredSuggestions : [],
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
- }, TYPEAHEAD_DEBOUNCE);
|
|
|
|
|
|
|
+ const 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);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- applyTypeahead(change, suggestion) {
|
|
|
|
|
- const { typeaheadPrefix, typeaheadContext, typeaheadText } = this.state;
|
|
|
|
|
|
|
+ group.items = _.sortBy(group.items, item => item.sortText || item.label);
|
|
|
|
|
+ }
|
|
|
|
|
+ return group;
|
|
|
|
|
+ })
|
|
|
|
|
+ .filter(group => group.items && group.items.length > 0); // Filter out empty groups
|
|
|
|
|
|
|
|
- // Modify suggestion based on context
|
|
|
|
|
- switch (typeaheadContext) {
|
|
|
|
|
- case 'context-labels': {
|
|
|
|
|
- const nextChar = getNextCharacter();
|
|
|
|
|
- if (!nextChar || nextChar === '}' || nextChar === ',') {
|
|
|
|
|
- suggestion += '=';
|
|
|
|
|
|
|
+ this.setState(
|
|
|
|
|
+ {
|
|
|
|
|
+ suggestions: filteredSuggestions,
|
|
|
|
|
+ typeaheadPrefix: prefix,
|
|
|
|
|
+ typeaheadContext: context,
|
|
|
|
|
+ typeaheadText: text,
|
|
|
|
|
+ },
|
|
|
|
|
+ () => {
|
|
|
|
|
+ if (refresher) {
|
|
|
|
|
+ refresher.then(this.handleTypeahead).catch(e => console.error(e));
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- break;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ }, TYPEAHEAD_DEBOUNCE);
|
|
|
|
|
|
|
|
- case 'context-label-values': {
|
|
|
|
|
- // Always add quotes and remove existing ones instead
|
|
|
|
|
- if (!(typeaheadText.startsWith('="') || typeaheadText.startsWith('"'))) {
|
|
|
|
|
- suggestion = `"${suggestion}`;
|
|
|
|
|
- }
|
|
|
|
|
- if (getNextCharacter() !== '"') {
|
|
|
|
|
- suggestion = `${suggestion}"`;
|
|
|
|
|
- }
|
|
|
|
|
- break;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ applyTypeahead(change: Change, suggestion: Suggestion): Change {
|
|
|
|
|
+ const { cleanText, onWillApplySuggestion } = this.props;
|
|
|
|
|
+ const { typeaheadPrefix, typeaheadText } = this.state;
|
|
|
|
|
+ let suggestionText = suggestion.insertText || suggestion.label;
|
|
|
|
|
+ const move = suggestion.move || 0;
|
|
|
|
|
|
|
|
- default:
|
|
|
|
|
|
|
+ if (onWillApplySuggestion) {
|
|
|
|
|
+ suggestionText = onWillApplySuggestion(suggestionText, { ...this.state });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
this.resetTypeahead();
|
|
this.resetTypeahead();
|
|
|
|
|
|
|
|
// Remove the current, incomplete text and replace it with the selected suggestion
|
|
// Remove the current, incomplete text and replace it with the selected suggestion
|
|
|
- let backward = typeaheadPrefix.length;
|
|
|
|
|
- const text = cleanText(typeaheadText);
|
|
|
|
|
|
|
+ const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
|
|
|
|
|
+ const text = cleanText ? cleanText(typeaheadText) : typeaheadText;
|
|
|
const suffixLength = text.length - typeaheadPrefix.length;
|
|
const suffixLength = text.length - typeaheadPrefix.length;
|
|
|
const offset = typeaheadText.indexOf(typeaheadPrefix);
|
|
const offset = typeaheadText.indexOf(typeaheadPrefix);
|
|
|
- const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestion === typeaheadText);
|
|
|
|
|
|
|
+ const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
|
|
|
const forward = midWord ? suffixLength + offset : 0;
|
|
const forward = midWord ? suffixLength + offset : 0;
|
|
|
|
|
|
|
|
- return (
|
|
|
|
|
- change
|
|
|
|
|
- // TODO this line breaks if cursor was moved left and length is longer than whole prefix
|
|
|
|
|
|
|
+ // If new-lines, apply suggestion as block
|
|
|
|
|
+ if (suggestionText.match(/\n/)) {
|
|
|
|
|
+ const fragment = makeFragment(suggestionText);
|
|
|
|
|
+ return change
|
|
|
.deleteBackward(backward)
|
|
.deleteBackward(backward)
|
|
|
.deleteForward(forward)
|
|
.deleteForward(forward)
|
|
|
- .insertText(suggestion)
|
|
|
|
|
- .focus()
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ .insertFragment(fragment)
|
|
|
|
|
+ .focus();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return change
|
|
|
|
|
+ .deleteBackward(backward)
|
|
|
|
|
+ .deleteForward(forward)
|
|
|
|
|
+ .insertText(suggestionText)
|
|
|
|
|
+ .move(move)
|
|
|
|
|
+ .focus();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
onKeyDown = (event, change) => {
|
|
onKeyDown = (event, change) => {
|
|
@@ -413,74 +379,6 @@ class QueryField extends React.Component<any, any> {
|
|
|
});
|
|
});
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- async fetchLabelValues(key) {
|
|
|
|
|
- const url = `/api/v1/label/${key}/values`;
|
|
|
|
|
- try {
|
|
|
|
|
- const res = await this.request(url);
|
|
|
|
|
- console.log(res);
|
|
|
|
|
- const body = await (res.data || res.json());
|
|
|
|
|
- const pairs = this.state.labelValues[EMPTY_METRIC];
|
|
|
|
|
- const values = {
|
|
|
|
|
- ...pairs,
|
|
|
|
|
- [key]: body.data,
|
|
|
|
|
- };
|
|
|
|
|
- // const labelKeys = {
|
|
|
|
|
- // ...this.state.labelKeys,
|
|
|
|
|
- // [EMPTY_METRIC]: keys,
|
|
|
|
|
- // };
|
|
|
|
|
- const labelValues = {
|
|
|
|
|
- ...this.state.labelValues,
|
|
|
|
|
- [EMPTY_METRIC]: values,
|
|
|
|
|
- };
|
|
|
|
|
- this.setState({ labelValues }, this.handleTypeahead);
|
|
|
|
|
- } catch (e) {
|
|
|
|
|
- if (this.props.onRequestError) {
|
|
|
|
|
- this.props.onRequestError(e);
|
|
|
|
|
- } else {
|
|
|
|
|
- console.error(e);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- async fetchMetricLabels(name) {
|
|
|
|
|
- const url = `/api/v1/series?match[]=${name}`;
|
|
|
|
|
- try {
|
|
|
|
|
- const res = await this.request(url);
|
|
|
|
|
- const body = await (res.data || res.json());
|
|
|
|
|
- const { keys, values } = processLabels(body.data);
|
|
|
|
|
- const labelKeys = {
|
|
|
|
|
- ...this.state.labelKeys,
|
|
|
|
|
- [name]: keys,
|
|
|
|
|
- };
|
|
|
|
|
- const labelValues = {
|
|
|
|
|
- ...this.state.labelValues,
|
|
|
|
|
- [name]: values,
|
|
|
|
|
- };
|
|
|
|
|
- this.setState({ labelKeys, labelValues }, this.handleTypeahead);
|
|
|
|
|
- } catch (e) {
|
|
|
|
|
- if (this.props.onRequestError) {
|
|
|
|
|
- this.props.onRequestError(e);
|
|
|
|
|
- } else {
|
|
|
|
|
- console.error(e);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- async fetchMetricNames() {
|
|
|
|
|
- const url = '/api/v1/label/__name__/values';
|
|
|
|
|
- try {
|
|
|
|
|
- const res = await this.request(url);
|
|
|
|
|
- const body = await (res.data || res.json());
|
|
|
|
|
- this.setState({ metrics: body.data }, this.onMetricsReceived);
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- if (this.props.onRequestError) {
|
|
|
|
|
- this.props.onRequestError(error);
|
|
|
|
|
- } else {
|
|
|
|
|
- console.error(error);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
handleBlur = () => {
|
|
handleBlur = () => {
|
|
|
const { onBlur } = this.props;
|
|
const { onBlur } = this.props;
|
|
|
// If we dont wait here, menu clicks wont work because the menu
|
|
// If we dont wait here, menu clicks wont work because the menu
|
|
@@ -498,7 +396,7 @@ class QueryField extends React.Component<any, any> {
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- handleClickMenu = item => {
|
|
|
|
|
|
|
+ onClickMenu = (item: Suggestion) => {
|
|
|
// Manually triggering change
|
|
// Manually triggering change
|
|
|
const change = this.applyTypeahead(this.state.value.change(), item);
|
|
const change = this.applyTypeahead(this.state.value.change(), item);
|
|
|
this.onChange(change);
|
|
this.onChange(change);
|
|
@@ -531,7 +429,7 @@ class QueryField extends React.Component<any, any> {
|
|
|
|
|
|
|
|
// Write DOM
|
|
// Write DOM
|
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
|
- menu.style.opacity = 1;
|
|
|
|
|
|
|
+ menu.style.opacity = '1';
|
|
|
menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
|
|
menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
|
|
|
menu.style.left = `${rect.left + scrollX - 2}px`;
|
|
menu.style.left = `${rect.left + scrollX - 2}px`;
|
|
|
});
|
|
});
|
|
@@ -554,17 +452,16 @@ class QueryField extends React.Component<any, any> {
|
|
|
let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
|
|
let selectedIndex = Math.max(this.state.typeaheadIndex, 0);
|
|
|
const flattenedSuggestions = flattenSuggestions(suggestions);
|
|
const flattenedSuggestions = flattenSuggestions(suggestions);
|
|
|
selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
|
|
selectedIndex = selectedIndex % flattenedSuggestions.length || 0;
|
|
|
- const selectedKeys = (flattenedSuggestions.length > 0 ? [flattenedSuggestions[selectedIndex]] : []).map(
|
|
|
|
|
- i => (typeof i === 'object' ? i.text : i)
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ const selectedItem: Suggestion | null =
|
|
|
|
|
+ flattenedSuggestions.length > 0 ? flattenedSuggestions[selectedIndex] : null;
|
|
|
|
|
|
|
|
// Create typeahead in DOM root so we can later position it absolutely
|
|
// Create typeahead in DOM root so we can later position it absolutely
|
|
|
return (
|
|
return (
|
|
|
<Portal prefix={portalPrefix}>
|
|
<Portal prefix={portalPrefix}>
|
|
|
<Typeahead
|
|
<Typeahead
|
|
|
menuRef={this.menuRef}
|
|
menuRef={this.menuRef}
|
|
|
- selectedItems={selectedKeys}
|
|
|
|
|
- onClickItem={this.handleClickMenu}
|
|
|
|
|
|
|
+ selectedItem={selectedItem}
|
|
|
|
|
+ onClickItem={this.onClickMenu}
|
|
|
groupedItems={suggestions}
|
|
groupedItems={suggestions}
|
|
|
/>
|
|
/>
|
|
|
</Portal>
|
|
</Portal>
|
|
@@ -591,4 +488,24 @@ class QueryField extends React.Component<any, any> {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+class Portal extends React.Component<{ index?: number; prefix: string }, {}> {
|
|
|
|
|
+ node: HTMLElement;
|
|
|
|
|
+
|
|
|
|
|
+ constructor(props) {
|
|
|
|
|
+ super(props);
|
|
|
|
|
+ const { index = 0, prefix = 'query' } = props;
|
|
|
|
|
+ this.node = document.createElement('div');
|
|
|
|
|
+ this.node.classList.add(`slate-typeahead`, `slate-typeahead-${prefix}-${index}`);
|
|
|
|
|
+ document.body.appendChild(this.node);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ componentWillUnmount() {
|
|
|
|
|
+ document.body.removeChild(this.node);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ render() {
|
|
|
|
|
+ return ReactDOM.createPortal(this.props.children, this.node);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export default QueryField;
|
|
export default QueryField;
|