mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2024-11-21 14:44:00 +00:00
vmui: trigger auto-suggestion at any cursor position (#6155)
- Implemented auto-suggestion triggers for mid-string cursor positions in vmui. - Improved the suggestion list positioning to appear directly beneath the active text editing area. https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5864
This commit is contained in:
parent
6aaf1768f4
commit
6193fa3dcf
8 changed files with 131 additions and 41 deletions
|
@ -9,6 +9,7 @@ import { partialWarning, seriesFetchedWarning } from "./warningText";
|
|||
import { AutocompleteOptions } from "../../Main/Autocomplete/Autocomplete";
|
||||
import useDeviceDetect from "../../../hooks/useDeviceDetect";
|
||||
import { useQueryState } from "../../../state/query/QueryStateContext";
|
||||
import debounce from "lodash.debounce";
|
||||
|
||||
export interface QueryEditorProps {
|
||||
onChange: (query: string) => void;
|
||||
|
@ -40,9 +41,12 @@ const QueryEditor: FC<QueryEditorProps> = ({
|
|||
const { isMobile } = useDeviceDetect();
|
||||
|
||||
const [openAutocomplete, setOpenAutocomplete] = useState(false);
|
||||
const [caretPosition, setCaretPosition] = useState([0, 0]);
|
||||
const [caretPosition, setCaretPosition] = useState<[number, number]>([0, 0]);
|
||||
const autocompleteAnchorEl = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [showAutocomplete, setShowAutocomplete] = useState(autocomplete);
|
||||
const debouncedSetShowAutocomplete = useRef(debounce(setShowAutocomplete, 500)).current;
|
||||
|
||||
const warning = [
|
||||
{
|
||||
show: stats?.seriesFetched === "0" && !stats.resultLength,
|
||||
|
@ -58,8 +62,9 @@ const QueryEditor: FC<QueryEditorProps> = ({
|
|||
label = `${label} (${stats.executionTimeMsec || 0}ms)`;
|
||||
}
|
||||
|
||||
const handleSelect = (val: string) => {
|
||||
const handleSelect = (val: string, caretPosition: number) => {
|
||||
onChange(val);
|
||||
setCaretPosition([caretPosition, caretPosition]);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
|
@ -100,14 +105,19 @@ const QueryEditor: FC<QueryEditorProps> = ({
|
|||
setOpenAutocomplete(!!val.length);
|
||||
};
|
||||
|
||||
const handleChangeCaret = (val: number[]) => {
|
||||
setCaretPosition(val);
|
||||
const handleChangeCaret = (val: [number, number]) => {
|
||||
setCaretPosition(prev => prev[0] === val[0] && prev[1] === val[1] ? prev : val);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setOpenAutocomplete(autocomplete);
|
||||
}, [autocompleteQuick]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowAutocomplete(false);
|
||||
debouncedSetShowAutocomplete(true);
|
||||
}, [caretPosition]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="vm-query-editor"
|
||||
|
@ -125,12 +135,14 @@ const QueryEditor: FC<QueryEditorProps> = ({
|
|||
onChangeCaret={handleChangeCaret}
|
||||
disabled={disabled}
|
||||
inputmode={"search"}
|
||||
caretPosition={caretPosition}
|
||||
/>
|
||||
{autocomplete && (
|
||||
{showAutocomplete && autocomplete && (
|
||||
<QueryEditorAutocomplete
|
||||
value={value}
|
||||
anchorEl={autocompleteAnchorEl}
|
||||
caretPosition={caretPosition}
|
||||
hasHelperText={Boolean(warning || error)}
|
||||
onSelect={handleSelect}
|
||||
onFoundOptions={handleChangeFoundOptions}
|
||||
/>
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import React, { FC, Ref, useState, useEffect, useMemo } from "preact/compat";
|
||||
import React, { FC, Ref, useState, useEffect, useMemo, useCallback } from "preact/compat";
|
||||
import Autocomplete, { AutocompleteOptions } from "../../Main/Autocomplete/Autocomplete";
|
||||
import { useFetchQueryOptions } from "../../../hooks/useFetchQueryOptions";
|
||||
import { getTextWidth } from "../../../utils/uplot";
|
||||
import { escapeRegexp, hasUnclosedQuotes } from "../../../utils/regexp";
|
||||
import useGetMetricsQL from "../../../hooks/useGetMetricsQL";
|
||||
import { QueryContextType } from "../../../types";
|
||||
|
@ -10,8 +9,9 @@ import { AUTOCOMPLETE_LIMITS } from "../../../constants/queryAutocomplete";
|
|||
interface QueryEditorAutocompleteProps {
|
||||
value: string;
|
||||
anchorEl: Ref<HTMLInputElement>;
|
||||
caretPosition: number[];
|
||||
onSelect: (val: string) => void;
|
||||
caretPosition: [number, number]; // [start, end]
|
||||
hasHelperText: boolean;
|
||||
onSelect: (val: string, caretPosition: number) => void;
|
||||
onFoundOptions: (val: AutocompleteOptions[]) => void;
|
||||
}
|
||||
|
||||
|
@ -19,16 +19,24 @@ const QueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
|
|||
value,
|
||||
anchorEl,
|
||||
caretPosition,
|
||||
hasHelperText,
|
||||
onSelect,
|
||||
onFoundOptions
|
||||
}) => {
|
||||
const [leftOffset, setLeftOffset] = useState(0);
|
||||
const [offsetPos, setOffsetPos] = useState({ top: 0, left: 0 });
|
||||
const metricsqlFunctions = useGetMetricsQL();
|
||||
|
||||
const values = useMemo(() => {
|
||||
if (caretPosition[0] !== caretPosition[1]) return { beforeCursor: value, afterCursor: "" };
|
||||
const beforeCursor = value.substring(0, caretPosition[0]);
|
||||
const afterCursor = value.substring(caretPosition[1]);
|
||||
return { beforeCursor, afterCursor };
|
||||
}, [value, caretPosition]);
|
||||
|
||||
const exprLastPart = useMemo(() => {
|
||||
const parts = value.split("}");
|
||||
const parts = values.beforeCursor.split("}");
|
||||
return parts[parts.length - 1];
|
||||
}, [value]);
|
||||
}, [values]);
|
||||
|
||||
const metric = useMemo(() => {
|
||||
const regexp = /\b[^{}(),\s]+(?={|$)/g;
|
||||
|
@ -43,7 +51,7 @@ const QueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
|
|||
}, [exprLastPart]);
|
||||
|
||||
const shouldSuppressAutoSuggestion = (value: string) => {
|
||||
const pattern = /([(),+\-*/^]|\b(?:or|and|unless|default|ifnot|if|group_left|group_right)\b)/;
|
||||
const pattern = /([{(),+\-*/^]|\b(?:or|and|unless|default|ifnot|if|group_left|group_right)\b)/;
|
||||
const parts = value.split(/\s+/);
|
||||
const partsCount = parts.length;
|
||||
const lastPart = parts[partsCount - 1];
|
||||
|
@ -55,7 +63,7 @@ const QueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
|
|||
};
|
||||
|
||||
const context = useMemo(() => {
|
||||
if (!value || value.endsWith("}") || shouldSuppressAutoSuggestion(value)) {
|
||||
if (!values.beforeCursor || values.beforeCursor.endsWith("}") || shouldSuppressAutoSuggestion(values.beforeCursor)) {
|
||||
return QueryContextType.empty;
|
||||
}
|
||||
|
||||
|
@ -63,19 +71,19 @@ const QueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
|
|||
const labelValueRegexp = new RegExp(`(${escapeRegexp(metric)})?{?.+${escapeRegexp(label)}(=|!=|=~|!~)"?([^"]*)$`, "g");
|
||||
|
||||
switch (true) {
|
||||
case labelValueRegexp.test(value):
|
||||
case labelValueRegexp.test(values.beforeCursor):
|
||||
return QueryContextType.labelValue;
|
||||
case labelRegexp.test(value):
|
||||
case labelRegexp.test(values.beforeCursor):
|
||||
return QueryContextType.label;
|
||||
default:
|
||||
return QueryContextType.metricsql;
|
||||
}
|
||||
}, [value, metric, label]);
|
||||
}, [values, metric, label]);
|
||||
|
||||
const valueByContext = useMemo(() => {
|
||||
const wordMatch = value.match(/([\w_\-.:/]+(?![},]))$/);
|
||||
const wordMatch = values.beforeCursor.match(/([\w_\-.:/]+(?![},]))$/);
|
||||
return wordMatch ? wordMatch[0] : "";
|
||||
}, [value]);
|
||||
}, [values.beforeCursor]);
|
||||
|
||||
const { metrics, labels, labelValues, loading } = useFetchQueryOptions({
|
||||
valueByContext,
|
||||
|
@ -97,8 +105,10 @@ const QueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
|
|||
}
|
||||
}, [context, metrics, labels, labelValues]);
|
||||
|
||||
const handleSelect = (insert: string) => {
|
||||
const handleSelect = useCallback((insert: string) => {
|
||||
// Find the start and end of valueByContext in the query string
|
||||
const value = values.beforeCursor;
|
||||
let valueAfterCursor = values.afterCursor;
|
||||
const startIndexOfValueByContext = value.lastIndexOf(valueByContext, caretPosition[0]);
|
||||
const endIndexOfValueByContext = startIndexOfValueByContext + valueByContext.length;
|
||||
|
||||
|
@ -110,26 +120,59 @@ const QueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
|
|||
if (context === QueryContextType.labelValue) {
|
||||
const quote = "\"";
|
||||
const needsQuote = /(?:=|!=|=~|!~)$/.test(beforeValueByContext);
|
||||
valueAfterCursor = valueAfterCursor.replace(/^[^\s"|},]*/, "");
|
||||
insert = `${needsQuote ? quote : ""}${insert}`;
|
||||
}
|
||||
|
||||
if (context === QueryContextType.label) {
|
||||
valueAfterCursor = valueAfterCursor.replace(/^[^\s=!,{}()"|+\-/*^]*/, "");
|
||||
}
|
||||
|
||||
if (context === QueryContextType.metricsql) {
|
||||
valueAfterCursor = valueAfterCursor.replace(/^[^\s[\]{}()"|+\-/*^]*/, "");
|
||||
}
|
||||
// Assemble the new value with the inserted text
|
||||
const newVal = `${beforeValueByContext}${insert}${afterValueByContext}`;
|
||||
onSelect(newVal);
|
||||
};
|
||||
const newVal = `${beforeValueByContext}${insert}${afterValueByContext}${valueAfterCursor}`;
|
||||
onSelect(newVal, beforeValueByContext.length + insert.length);
|
||||
}, [values]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchorEl.current) {
|
||||
setLeftOffset(0);
|
||||
setOffsetPos({ top: 0, left: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(anchorEl.current);
|
||||
const element = anchorEl.current.querySelector("textarea") || anchorEl.current;
|
||||
const style = window.getComputedStyle(element);
|
||||
const fontSize = `${style.getPropertyValue("font-size")}`;
|
||||
const fontFamily = `${style.getPropertyValue("font-family")}`;
|
||||
const offset = getTextWidth(value, `${fontSize} ${fontFamily}`);
|
||||
setLeftOffset(offset);
|
||||
}, [anchorEl, caretPosition]);
|
||||
const lineHeight = parseInt(`${style.getPropertyValue("line-height")}`);
|
||||
|
||||
const span = document.createElement("div");
|
||||
span.style.font = `${fontSize} ${fontFamily}`;
|
||||
span.style.padding = style.getPropertyValue("padding");
|
||||
span.style.lineHeight = `${lineHeight}px`;
|
||||
span.style.width = `${element.offsetWidth}px`;
|
||||
span.style.maxWidth = `${element.offsetWidth}px`;
|
||||
span.style.whiteSpace = style.getPropertyValue("white-space");
|
||||
span.style.overflowWrap = style.getPropertyValue("overflow-wrap");
|
||||
|
||||
const marker = document.createElement("span");
|
||||
span.appendChild(document.createTextNode(values.beforeCursor));
|
||||
span.appendChild(marker);
|
||||
span.appendChild(document.createTextNode(values.afterCursor));
|
||||
document.body.appendChild(span);
|
||||
|
||||
const spanRect = span.getBoundingClientRect();
|
||||
const markerRect = marker.getBoundingClientRect();
|
||||
|
||||
const leftOffset = markerRect.left - spanRect.left;
|
||||
const topOffset = markerRect.bottom - spanRect.bottom - (hasHelperText ? lineHeight : 0);
|
||||
setOffsetPos({ top: topOffset, left: leftOffset });
|
||||
|
||||
span.remove();
|
||||
marker.remove();
|
||||
}, [anchorEl, caretPosition, hasHelperText]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -140,7 +183,7 @@ const QueryEditorAutocomplete: FC<QueryEditorAutocompleteProps> = ({
|
|||
options={options}
|
||||
anchor={anchorEl}
|
||||
minLength={0}
|
||||
offset={{ top: 0, left: leftOffset }}
|
||||
offset={offsetPos}
|
||||
onSelect={handleSelect}
|
||||
onFoundOptions={onFoundOptions}
|
||||
maxDisplayResults={{
|
||||
|
|
|
@ -2,4 +2,13 @@
|
|||
|
||||
.vm-query-editor {
|
||||
position: relative;
|
||||
|
||||
.marker-detection {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
z-index: -9999;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -120,7 +120,7 @@ const Autocomplete: FC<AutocompleteProps> = ({
|
|||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
const { key, ctrlKey, metaKey, shiftKey } = e;
|
||||
const modifiers = ctrlKey || metaKey || shiftKey;
|
||||
const hasOptions = foundOptions.length;
|
||||
const hasOptions = foundOptions.length && !hideFoundedOptions;
|
||||
|
||||
if (key === "ArrowUp" && !modifiers && hasOptions) {
|
||||
e.preventDefault();
|
||||
|
@ -148,7 +148,7 @@ const Autocomplete: FC<AutocompleteProps> = ({
|
|||
if (key === "Escape") {
|
||||
handleCloseAutocomplete();
|
||||
}
|
||||
}, [focusOption, foundOptions, handleCloseAutocomplete, onSelect, selected]);
|
||||
}, [focusOption, foundOptions, hideFoundedOptions, handleCloseAutocomplete, onSelect, selected]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpenAutocomplete(value.length >= minLength);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React, {
|
||||
FC,
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
useMemo,
|
||||
FormEvent,
|
||||
|
@ -28,12 +29,13 @@ interface TextFieldProps {
|
|||
autofocus?: boolean
|
||||
helperText?: string
|
||||
inputmode?: "search" | "text" | "email" | "tel" | "url" | "none" | "numeric" | "decimal"
|
||||
caretPosition?: [number, number]
|
||||
onChange?: (value: string) => void
|
||||
onEnter?: () => void
|
||||
onKeyDown?: (e: KeyboardEvent) => void
|
||||
onFocus?: () => void
|
||||
onBlur?: () => void
|
||||
onChangeCaret?: (position: number[]) => void
|
||||
onChangeCaret?: (position: [number, number]) => void
|
||||
}
|
||||
|
||||
const TextField: FC<TextFieldProps> = ({
|
||||
|
@ -49,6 +51,7 @@ const TextField: FC<TextFieldProps> = ({
|
|||
disabled = false,
|
||||
autofocus = false,
|
||||
inputmode = "text",
|
||||
caretPosition,
|
||||
onChange,
|
||||
onEnter,
|
||||
onKeyDown,
|
||||
|
@ -62,6 +65,7 @@ const TextField: FC<TextFieldProps> = ({
|
|||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fieldRef = useMemo(() => type === "textarea" ? textareaRef : inputRef, [type]);
|
||||
const [selectionPos, setSelectionPos] = useState<[start: number, end: number]>([0, 0]);
|
||||
|
||||
const inputClasses = classNames({
|
||||
"vm-text-field__input": true,
|
||||
|
@ -74,7 +78,7 @@ const TextField: FC<TextFieldProps> = ({
|
|||
|
||||
const updateCaretPosition = (target: HTMLInputElement | HTMLTextAreaElement) => {
|
||||
const { selectionStart, selectionEnd } = target;
|
||||
onChangeCaret && onChangeCaret([selectionStart || 0, selectionEnd || 0]);
|
||||
setSelectionPos([selectionStart || 0, selectionEnd || 0]);
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: MouseEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
|
@ -102,11 +106,6 @@ const TextField: FC<TextFieldProps> = ({
|
|||
updateCaretPosition(e.currentTarget);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!autofocus || isMobile) return;
|
||||
fieldRef?.current?.focus && fieldRef.current.focus();
|
||||
}, [fieldRef, autofocus]);
|
||||
|
||||
const handleFocus = () => {
|
||||
onFocus && onFocus();
|
||||
};
|
||||
|
@ -115,6 +114,31 @@ const TextField: FC<TextFieldProps> = ({
|
|||
onBlur && onBlur();
|
||||
};
|
||||
|
||||
const setSelectionRange = (range: [number, number]) => {
|
||||
try {
|
||||
fieldRef.current && fieldRef.current.setSelectionRange(range[0], range[1]);
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!autofocus || isMobile) return;
|
||||
fieldRef?.current?.focus && fieldRef.current.focus();
|
||||
}, [fieldRef, autofocus]);
|
||||
|
||||
useEffect(() => {
|
||||
onChangeCaret && onChangeCaret(selectionPos);
|
||||
}, [selectionPos]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectionRange(selectionPos);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
caretPosition && setSelectionRange(caretPosition);
|
||||
}, [caretPosition]);
|
||||
|
||||
return <label
|
||||
className={classNames({
|
||||
"vm-text-field": true,
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
&_textarea:after {
|
||||
content: attr(data-replicated-value) " ";
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
|
|
@ -96,6 +96,7 @@ export const useFetchQueryOptions = ({ valueByContext, metric, label, context }:
|
|||
const cachedData = autocompleteCache.get(key);
|
||||
if (cachedData) {
|
||||
setter(processData(cachedData, type));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`${serverUrl}/api/v1/${urlSuffix}?${params}`, { signal });
|
||||
|
@ -104,13 +105,13 @@ export const useFetchQueryOptions = ({ valueByContext, metric, label, context }:
|
|||
setter(processData(data, type));
|
||||
queryDispatch({ type: "SET_AUTOCOMPLETE_CACHE", payload: { key, value: data } });
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name !== "AbortError") {
|
||||
queryDispatch({ type: "SET_AUTOCOMPLETE_CACHE", payload: { key, value: [] } });
|
||||
setLoading(false);
|
||||
console.error(e);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -41,6 +41,7 @@ See also [LTS releases](https://docs.victoriametrics.com/lts-releases/).
|
|||
* FEATURE: [vmauth](https://docs.victoriametrics.com/vmauth/): support regex matching when routing incoming requests based on HTTP [query args](https://en.wikipedia.org/wiki/Query_string) via `src_query_args` option at `url_map`. See [these docs](https://docs.victoriametrics.com/vmauth/#generic-http-proxy-for-different-backends) and [this feature request](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/6070).
|
||||
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): optimize auto-suggestion performance for metric names when the database contains big number of unique time series.
|
||||
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): in the Select component, user-entered values are now preserved on blur if they match options in the list.
|
||||
* FEATURE: [vmui](https://docs.victoriametrics.com/#vmui): auto-suggestion triggers at any cursor position in the query input. See [this issue](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/5864).
|
||||
|
||||
* BUGFIX: [downsampling](https://docs.victoriametrics.com/#downsampling): skip unnecessary index lookups if downsampling wasn't set for ENT versions of VictoriaMetrics. Before, users of VictoriaMetrics ENT could have experience elevated CPU usage even if no downsampling was configured. The issue was introduced in [v1.100.0](https://docs.victoriametrics.com/changelog/#v11000).
|
||||
* BUGFIX: [downsampling](https://docs.victoriametrics.com/#downsampling): properly populate downsampling metadata for data parts created by VictoriaMetrics ENT versions lower than v1.100.0. The bug could trigger the downsampling actions for parts that were downsampled already. This bug doesn't have any negative effect apart from spending extra CPU resources on the repeated downsampling. The issue was introduced in [v1.100.0](https://docs.victoriametrics.com/changelog/#v11000).
|
||||
|
|
Loading…
Reference in a new issue