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:
Yury Molodov 2024-04-25 12:48:49 +02:00 committed by GitHub
parent 6aaf1768f4
commit 6193fa3dcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 131 additions and 41 deletions

View file

@ -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}
/>

View file

@ -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={{

View file

@ -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;
}
}

View file

@ -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);

View file

@ -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,

View file

@ -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;
}

View file

@ -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);
}
};

View file

@ -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).