Yunus M 3a952fa330
fix: pass metric name to get value suggestions api (#8671)
* fix: pass metric name to get value suggestions api

* feat: add source to get value suggestions
2025-08-11 08:10:31 +00:00

1459 lines
41 KiB
TypeScript

/* eslint-disable sonarjs/no-identical-functions */
/* eslint-disable sonarjs/cognitive-complexity */
import './QuerySearch.styles.scss';
import { CheckCircleFilled } from '@ant-design/icons';
import {
autocompletion,
closeCompletion,
CompletionContext,
completionKeymap,
CompletionResult,
startCompletion,
} from '@codemirror/autocomplete';
import { javascript } from '@codemirror/lang-javascript';
import { Color } from '@signozhq/design-tokens';
import { copilot } from '@uiw/codemirror-theme-copilot';
import { githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
import { Button, Card, Collapse, Popover, Tag, Tooltip } from 'antd';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import cx from 'classnames';
import {
negationQueryOperatorSuggestions,
OPERATORS,
QUERY_BUILDER_KEY_TYPES,
QUERY_BUILDER_OPERATORS_BY_KEY_TYPE,
queryOperatorSuggestions,
} from 'constants/antlrQueryConstants';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
import { debounce, isNull } from 'lodash-es';
import { Info, TriangleAlert } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
IDetailedError,
IQueryContext,
IValidationResult,
} from 'types/antlrQueryTypes';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { DataSource } from 'types/common/queryBuilder';
import {
getCurrentValueIndexAtCursor,
getQueryContextAtCursor,
} from 'utils/queryContextUtils';
import { validateQuery } from 'utils/queryValidationUtils';
import { unquote } from 'utils/stringUtils';
import { queryExamples } from './constants';
const { Panel } = Collapse;
// Custom extension to stop events
const stopEventsExtension = EditorView.domEventHandlers({
keydown: (event) => {
// Stop all keyboard events from propagating to global shortcuts
event.stopPropagation();
event.stopImmediatePropagation();
return false; // Important for CM to know you handled it
},
input: (event) => {
event.stopPropagation();
return false;
},
focus: (event) => {
// Ensure focus events don't interfere with global shortcuts
event.stopPropagation();
return false;
},
blur: (event) => {
// Ensure blur events don't interfere with global shortcuts
event.stopPropagation();
return false;
},
});
function QuerySearch({
onChange,
queryData,
dataSource,
onRun,
signalSource,
}: {
onChange: (value: string) => void;
queryData: IBuilderQuery;
dataSource: DataSource;
signalSource?: string;
onRun?: (query: string) => void;
}): JSX.Element {
const isDarkMode = useIsDarkMode();
const [query, setQuery] = useState<string>(queryData.filter?.expression || '');
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
const [activeKey, setActiveKey] = useState<string>('');
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
const [queryContext, setQueryContext] = useState<IQueryContext | null>(null);
const [validation, setValidation] = useState<IValidationResult>({
isValid: false,
message: '',
errors: [],
});
const handleQueryValidation = (newQuery: string): void => {
try {
const validationResponse = validateQuery(newQuery);
setValidation(validationResponse);
} catch (error) {
setValidation({
isValid: false,
message: 'Failed to process query',
errors: [error as IDetailedError],
});
}
};
// Track if the query was changed externally (from queryData) vs internally (user input)
const [isExternalQueryChange, setIsExternalQueryChange] = useState(false);
const [lastExternalQuery, setLastExternalQuery] = useState<string>('');
useEffect(() => {
const newQuery = queryData.filter?.expression || '';
// Only mark as external change if the query actually changed from external source
if (newQuery !== lastExternalQuery) {
setQuery(newQuery);
setIsExternalQueryChange(true);
setLastExternalQuery(newQuery);
}
}, [queryData.filter?.expression, lastExternalQuery]);
// Validate query when it changes externally (from queryData)
useEffect(() => {
if (isExternalQueryChange && query) {
handleQueryValidation(query);
setIsExternalQueryChange(false);
}
}, [isExternalQueryChange, query]);
const [keySuggestions, setKeySuggestions] = useState<
QueryKeyDataSuggestionsProps[] | null
>(null);
const [showExamples] = useState(false);
const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 });
const [isFocused, setIsFocused] = useState(false);
const [
isFetchingCompleteValuesList,
setIsFetchingCompleteValuesList,
] = useState<boolean>(false);
const lastPosRef = useRef<{ line: number; ch: number }>({ line: 0, ch: 0 });
// Reference to the editor view for programmatic autocompletion
const editorRef = useRef<EditorView | null>(null);
const lastKeyRef = useRef<string>('');
const lastFetchedKeyRef = useRef<string>('');
const lastValueRef = useRef<string>('');
const isMountedRef = useRef<boolean>(true);
const { handleRunQuery } = useQueryBuilder();
// const {
// data: queryKeySuggestions,
// refetch: refetchQueryKeySuggestions,
// } = useGetQueryKeySuggestions({
// signal: dataSource,
// name: searchText || '',
// });
// Add back the generateOptions function and useEffect
const generateOptions = (keys: {
[key: string]: QueryKeyDataSuggestionsProps[];
}): any[] =>
Object.values(keys).flatMap((items: QueryKeyDataSuggestionsProps[]) =>
items.map(({ name, fieldDataType }) => ({
label: name,
type: fieldDataType === 'string' ? 'keyword' : fieldDataType,
info: '',
details: '',
})),
);
// Debounce the metric name to prevent API calls on every keystroke
const debouncedMetricName = useDebounce(
queryData.aggregateAttribute?.key || '',
500,
);
const toggleSuggestions = useCallback(
(timeout?: number) => {
const timeoutId = setTimeout(() => {
if (!editorRef.current) return;
if (isFocused) {
startCompletion(editorRef.current);
} else {
closeCompletion(editorRef.current);
}
}, timeout);
return (): void => clearTimeout(timeoutId);
},
[isFocused],
);
const fetchKeySuggestions = useCallback(
async (searchText?: string): Promise<void> => {
if (
dataSource === DataSource.METRICS &&
!queryData.aggregateAttribute?.key
) {
setKeySuggestions([]);
return;
}
lastFetchedKeyRef.current = searchText || '';
const response = await getKeySuggestions({
signal: dataSource,
searchText: searchText || '',
metricName: debouncedMetricName ?? undefined,
signalSource: signalSource as 'meter' | '',
});
if (response.data.data) {
const { keys } = response.data.data;
const options = generateOptions(keys);
// Use a Map to deduplicate by label and preserve order: new options take precedence
const merged = new Map<string, QueryKeyDataSuggestionsProps>();
options.forEach((opt) => merged.set(opt.label, opt));
if (searchText && lastKeyRef.current !== searchText) {
(keySuggestions || []).forEach((opt) => {
if (!merged.has(opt.label)) merged.set(opt.label, opt);
});
}
setKeySuggestions(Array.from(merged.values()));
// Force reopen the completion if editor is available and focused
if (editorRef.current) {
toggleSuggestions(10);
}
}
},
[
dataSource,
debouncedMetricName,
keySuggestions,
toggleSuggestions,
queryData.aggregateAttribute?.key,
signalSource,
],
);
const debouncedFetchKeySuggestions = useMemo(
() => debounce(fetchKeySuggestions, 300),
[fetchKeySuggestions],
);
useEffect(() => {
setKeySuggestions([]);
debouncedFetchKeySuggestions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSource, debouncedMetricName]);
// Add a state for tracking editing mode
const [editingMode, setEditingMode] = useState<
| 'key'
| 'operator'
| 'value'
| 'conjunction'
| 'function'
| 'parenthesis'
| 'bracketList'
| null
>(null);
// Helper function to wrap string values in quotes if they aren't already quoted
const wrapStringValueInQuotes = (value: string): string => {
// If value is already quoted (with single quotes), return as is
if (/^'.*'$/.test(value)) {
return value;
}
// If value contains single quotes, escape them and wrap in single quotes
if (value.includes("'")) {
// Replace single quotes with escaped single quotes
const escapedValue = value.replace(/'/g, "\\'");
return `'${escapedValue}'`;
}
// Otherwise, simply wrap in single quotes
return `'${value}'`;
};
// Helper function to check if operator is for list operations (IN, NOT IN, etc.)
const isListOperator = (op: string | undefined): boolean => {
if (!op) return false;
return op.toUpperCase() === 'IN' || op.toUpperCase() === 'NOT IN';
};
const formatValueForOperator = (
value: string,
operatorToken: string | undefined,
type: string,
): string => {
// If operator requires a list and value isn't already in list format
if (isListOperator(operatorToken) && !value.startsWith('[')) {
// For string values, wrap in quotes first, then in brackets
if (type === 'value' || type === 'keyword') {
const quotedValue = wrapStringValueInQuotes(value);
return `[${quotedValue}]`;
}
// For numbers, just wrap in brackets
return `[${value}]`;
}
// If we're already inside bracket list for IN operator and it's a string value
// just wrap in quotes but not brackets (we're already in brackets)
if (type === 'value' || type === 'keyword') {
return wrapStringValueInQuotes(value);
}
return value;
};
// Add cleanup effect to prevent component updates after unmount
useEffect(
(): (() => void) => (): void => {
// Mark component as unmounted to prevent state updates
isMountedRef.current = false;
},
[],
);
// Use callback to prevent dependency changes on each render
const fetchValueSuggestions = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
async ({
key,
searchText,
fetchingComplete = false,
}: {
key: string;
searchText?: string;
fetchingComplete?: boolean;
}): Promise<void> => {
if (
!key ||
(key === activeKey && !isLoadingSuggestions && !fetchingComplete) ||
!isMountedRef.current
)
return;
// Set loading state and store the key we're fetching for
setIsLoadingSuggestions(true);
if (fetchingComplete) {
setIsFetchingCompleteValuesList(true);
}
lastKeyRef.current = key;
lastValueRef.current = searchText || '';
setActiveKey(key);
setValueSuggestions([
{
label: 'Loading suggestions...',
type: 'text',
boost: -99,
apply: (): boolean => false,
},
]);
// Force reopen the completion if editor is available and focused
if (editorRef.current) {
toggleSuggestions(10);
}
const sanitizedSearchText = searchText ? searchText?.trim() : '';
try {
const response = await getValueSuggestions({
key,
searchText: sanitizedSearchText,
signal: dataSource,
signalSource: signalSource as 'meter' | '',
metricName: debouncedMetricName ?? undefined,
});
// Skip updates if component unmounted or key changed
if (
!isMountedRef.current ||
lastKeyRef.current !== key ||
lastValueRef.current !== sanitizedSearchText
) {
return; // Skip updating if key has changed or component unmounted
}
// Process the response data
const responseData = response.data as any;
const values = responseData.data?.values || {};
const stringValues = values.stringValues || [];
const numberValues = values.numberValues || [];
// Generate options from string values - explicitly handle empty strings
const stringOptions = stringValues
// Strict filtering for empty string - we'll handle it as a special case if needed
.filter(
(value: string | null | undefined): value is string =>
value !== null && value !== undefined && value !== '',
)
.map((value: string) => ({
label: value,
type: 'value',
apply: value,
}));
// Generate options from number values
const numberOptions = numberValues
.filter(
(value: number | null | undefined): value is number =>
value !== null && value !== undefined,
)
.map((value: number) => ({
label: value.toString(),
type: 'number',
apply: value,
}));
// Combine all options and make sure we don't have duplicate labels
let allOptions = [...stringOptions, ...numberOptions];
// Remove duplicates by label
allOptions = allOptions.filter(
(option, index, self) =>
index === self.findIndex((o) => o.label === option.label),
);
if (lastKeyRef.current === key && isMountedRef.current) {
if (allOptions.length > 0) {
setValueSuggestions(allOptions);
} else {
setValueSuggestions([
{
label: 'No suggestions available',
type: 'text',
boost: -99,
apply: (): boolean => false,
},
]);
}
// Force reopen the completion if editor is available and focused
if (editorRef.current) {
toggleSuggestions(10);
}
}
} catch (error) {
console.error('Error fetching suggestions:', error);
if (lastKeyRef.current === key && isMountedRef.current) {
setValueSuggestions([
{
label: 'Error loading suggestions',
type: 'text',
boost: -99, // Lower boost to appear at the bottom
apply: (): boolean => false, // Prevent selection
},
]);
}
} finally {
setIsLoadingSuggestions(false);
setIsFetchingCompleteValuesList(false);
}
},
[
activeKey,
dataSource,
isLoadingSuggestions,
debouncedMetricName,
signalSource,
toggleSuggestions,
],
);
const debouncedFetchValueSuggestions = useMemo(
() => debounce(fetchValueSuggestions, 300),
[fetchValueSuggestions],
);
const handleUpdate = useCallback((viewUpdate: { view: EditorView }): void => {
if (!isMountedRef.current) return;
if (!editorRef.current) {
editorRef.current = viewUpdate.view;
}
const selection = viewUpdate.view.state.selection.main;
const pos = selection.head;
const doc = viewUpdate.view.state.doc.toString();
const lineInfo = viewUpdate.view.state.doc.lineAt(pos);
const newPos = {
line: lineInfo.number,
ch: pos - lineInfo.from,
};
const lastPos = lastPosRef.current;
if (newPos.line !== lastPos.line || newPos.ch !== lastPos.ch) {
setCursorPos(newPos);
lastPosRef.current = newPos;
if (doc) {
const context = getQueryContextAtCursor(doc, pos);
let newContextType:
| 'key'
| 'operator'
| 'value'
| 'conjunction'
| 'function'
| 'parenthesis'
| 'bracketList'
| null = null;
if (context.isInKey) newContextType = 'key';
else if (context.isInOperator) newContextType = 'operator';
else if (context.isInValue) newContextType = 'value';
else if (context.isInConjunction) newContextType = 'conjunction';
else if (context.isInFunction) newContextType = 'function';
else if (context.isInParenthesis) newContextType = 'parenthesis';
else if (context.isInBracketList) newContextType = 'bracketList';
setQueryContext(context);
// Update editing mode based on context
setEditingMode(newContextType);
}
}
}, []);
const handleChange = (value: string): void => {
setQuery(value);
onChange(value);
// Mark as internal change to avoid triggering external validation
setIsExternalQueryChange(false);
// Update lastExternalQuery to prevent external validation trigger
setLastExternalQuery(value);
};
const handleBlur = (): void => {
handleQueryValidation(query);
setIsFocused(false);
};
useEffect(
() => (): void => {
if (debouncedFetchValueSuggestions) {
debouncedFetchValueSuggestions.cancel();
}
if (debouncedFetchKeySuggestions) {
debouncedFetchKeySuggestions.cancel();
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleExampleClick = (exampleQuery: string): void => {
// If there's an existing query, append the example with AND
const newQuery = query ? `${query} AND ${exampleQuery}` : exampleQuery;
setQuery(newQuery);
// Mark as internal change to avoid triggering external validation
setIsExternalQueryChange(false);
// Update lastExternalQuery to prevent external validation trigger
setLastExternalQuery(newQuery);
};
// Helper function to render a badge for the current context mode
const renderContextBadge = (): JSX.Element => {
if (!editingMode) return <Tag>Unknown</Tag>;
switch (editingMode) {
case 'key':
return <Tag color="blue">Key</Tag>;
case 'operator':
return <Tag color="purple">Operator</Tag>;
case 'value':
return <Tag color="green">Value</Tag>;
case 'conjunction':
return <Tag color="orange">Conjunction</Tag>;
case 'function':
return <Tag color="cyan">Function</Tag>;
case 'parenthesis':
return <Tag color="magenta">Parenthesis</Tag>;
case 'bracketList':
return <Tag color="red">Bracket List</Tag>;
default:
return <Tag>Unknown</Tag>;
}
};
// Enhanced myCompletions function to better use context including query pairs
// eslint-disable-next-line sonarjs/cognitive-complexity
function autoSuggestions(context: CompletionContext): CompletionResult | null {
// This matches words before the cursor position
// eslint-disable-next-line no-useless-escape
const word = context.matchBefore(/[a-zA-Z0-9_.:/?&=#%\-\[\]]*/);
if (word?.from === word?.to && !context.explicit) return null;
// Get the query context at the cursor position
const queryContext = getQueryContextAtCursor(query, cursorPos.ch);
// Define autocomplete options based on the context
let options: {
label: string;
type: string;
info?: string;
apply?:
| string
| ((view: EditorView, completion: any, from: number, to: number) => void);
detail?: string;
boost?: number;
}[] = [];
// Helper function to add space after selection
const addSpaceAfterSelection = (
view: EditorView,
completion: any,
from: number,
to: number,
shouldAddSpace = true,
): void => {
view.dispatch({
changes: {
from,
to,
insert: shouldAddSpace ? `${completion.apply} ` : `${completion.apply}`,
},
selection: {
anchor:
from +
(shouldAddSpace ? completion.apply.length + 1 : completion.apply.length),
},
});
};
// Helper function to add space after selection to options
const addSpaceToOptions = (opts: typeof options): typeof options =>
opts.map((option) => {
const originalApply = option.apply || option.label;
return {
...option,
apply: (
view: EditorView,
completion: any,
from: number,
to: number,
): void => {
let shouldDefaultApply = true;
// Changes to replace the value in-place with the existing value
const isValueType = queryContext.isInValue && option.type === 'value';
const isOperatorType =
queryContext.isInOperator && option.type === 'operator';
const pair = queryContext.currentPair;
if (isValueType) {
if (queryContext.isInBracketList && pair?.valuesPosition) {
const idx = getCurrentValueIndexAtCursor(
pair.valuesPosition,
cursorPos.ch,
);
if (!isNull(idx)) {
const { start, end } = pair.valuesPosition[idx];
if (
typeof start === 'number' &&
typeof end === 'number' &&
cursorPos.ch >= start &&
cursorPos.ch <= end + 1
) {
shouldDefaultApply = false;
addSpaceAfterSelection(
view,
{ apply: originalApply },
start,
end + 1,
false,
);
}
}
} else if (pair?.position) {
const { valueStart, valueEnd } = pair.position;
if (
typeof valueStart === 'number' &&
typeof valueEnd === 'number' &&
cursorPos.ch >= valueStart &&
cursorPos.ch <= valueEnd + 1
) {
shouldDefaultApply = false;
addSpaceAfterSelection(
view,
{ apply: originalApply },
valueStart,
valueEnd + 1,
false,
);
}
}
}
// Changes to replace the operator in-place with the existing operator
if (isOperatorType && pair?.position) {
const { operatorStart, operatorEnd } = pair.position;
if (
typeof operatorStart === 'number' &&
typeof operatorEnd === 'number' &&
operatorStart !== 0 &&
operatorEnd !== 0 &&
cursorPos.ch >= operatorStart &&
cursorPos.ch <= operatorEnd + 1
) {
shouldDefaultApply = false;
addSpaceAfterSelection(
view,
{ apply: originalApply },
operatorStart,
operatorEnd + 1,
false,
);
}
}
if (shouldDefaultApply) {
addSpaceAfterSelection(view, { apply: originalApply }, from, to);
}
},
};
});
// Special handling for bracket list context (for IN operator)
if (queryContext.isInBracketList) {
// If we're inside brackets for an IN operator, we want to show value suggestions
// but format them differently (just add quotes, don't wrap in brackets)
const keyName = queryContext.keyToken || queryContext.currentPair?.key || '';
if (!keyName) {
return null;
}
let searchText = '';
if (
queryContext.currentPair &&
queryContext.currentPair.valuesPosition &&
queryContext.currentPair.valueList
) {
const { valuesPosition, valueList } = queryContext.currentPair;
const idx = getCurrentValueIndexAtCursor(valuesPosition, cursorPos.ch);
searchText = isNull(idx)
? ''
: unquote(valueList[idx]).toLowerCase().trim();
}
options = (valueSuggestions || []).filter((option) =>
option.label.toLowerCase().includes(searchText),
);
const shouldFetch =
// Fetch only if key is available
keyName &&
// Fetch if either there's no suggestion left with the current searchText or searchText is empty
(((options.length === 0 || searchText === '') &&
lastValueRef.current !== searchText &&
!isFetchingCompleteValuesList) ||
keyName !== activeKey ||
isLoadingSuggestions) &&
!(isLoadingSuggestions && lastKeyRef.current === keyName);
if (shouldFetch) {
debouncedFetchValueSuggestions({
key: keyName,
searchText,
fetchingComplete: true,
});
}
// For values in bracket list, just add quotes without enclosing in brackets
const processedOptions = options.map((option) => {
// Clone the option to avoid modifying the original
const processedOption = { ...option };
// Skip processing for non-selectable items
if (!option.apply || typeof option.apply === 'function') {
return option;
}
// For strings, just wrap in quotes (no brackets needed)
if (option.type === 'value' || option.type === 'keyword') {
processedOption.apply = wrapStringValueInQuotes(option.label);
} else {
processedOption.apply = option.label;
}
return processedOption;
});
// Add space after selection
const optionsWithSpace = addSpaceToOptions(processedOptions);
// Return current value suggestions without comma
return {
from: word?.from ?? 0,
options: optionsWithSpace,
};
}
if (queryContext.isInKey) {
const searchText = word?.text.toLowerCase().trim() ?? '';
options = (keySuggestions || []).filter((option) =>
option.label.toLowerCase().includes(searchText),
);
if (options.length === 0 && lastFetchedKeyRef.current !== searchText) {
debouncedFetchKeySuggestions(searchText);
}
// If we have previous pairs, we can prioritize keys that haven't been used yet
if (queryContext.queryPairs && queryContext.queryPairs.length > 0) {
const usedKeys = queryContext.queryPairs.map((pair) => pair.key);
// Add boost to unused keys to prioritize them
options = options.map((option) => ({
...option,
boost: usedKeys.includes(option.label) ? -10 : 10,
}));
}
// Add boost to exact matches
options = options.map((option) => ({
...option,
boost:
(option.boost || 0) +
(option.label.toLowerCase() === searchText ? 100 : 0),
}));
// Add space after selection for keys
const optionsWithSpace = addSpaceToOptions(options);
return {
from: word?.from ?? 0,
to: word?.to ?? cursorPos.ch,
options: optionsWithSpace,
};
}
if (queryContext.isInOperator) {
options = queryOperatorSuggestions;
// Get key information from context or current pair
const keyName = queryContext.keyToken || queryContext.currentPair?.key;
if (queryContext.currentPair?.hasNegation) {
options = negationQueryOperatorSuggestions;
}
// If we have a key context, add that info to the operator suggestions
if (keyName) {
// Find the key details from suggestions
const keyDetails = (keySuggestions || []).find((k) => k.label === keyName);
const keyType = keyDetails?.type || '';
// Filter operators based on key type
if (keyType) {
if (keyType === QUERY_BUILDER_KEY_TYPES.NUMBER) {
// Prioritize numeric operators
options = options
.filter((op) =>
QUERY_BUILDER_OPERATORS_BY_KEY_TYPE[
QUERY_BUILDER_KEY_TYPES.NUMBER
].includes(op.label),
)
.map((op) => ({
...op,
boost: ['>', '<', '>=', '<=', '=', '!=', 'BETWEEN'].includes(op.label)
? 100
: 0,
}));
} else if (
keyType === QUERY_BUILDER_KEY_TYPES.STRING ||
keyType === 'keyword'
) {
// Prioritize string operators
options = options
.filter((op) =>
QUERY_BUILDER_OPERATORS_BY_KEY_TYPE[
QUERY_BUILDER_KEY_TYPES.STRING
].includes(op.label),
)
.map((op) => {
if (op.label === OPERATORS['=']) {
return {
...op,
boost: 200,
};
}
if (
[
OPERATORS['!='],
OPERATORS.LIKE,
OPERATORS.ILIKE,
OPERATORS.CONTAINS,
OPERATORS.IN,
].includes(op.label)
) {
return {
...op,
boost: 100,
};
}
return {
...op,
boost: 0,
};
});
} else if (keyType === QUERY_BUILDER_KEY_TYPES.BOOLEAN) {
// Prioritize boolean operators
options = options
.filter((op) =>
QUERY_BUILDER_OPERATORS_BY_KEY_TYPE[
QUERY_BUILDER_KEY_TYPES.BOOLEAN
].includes(op.label),
)
.map((op) => {
if (op.label === OPERATORS['=']) {
return {
...op,
boost: 200,
};
}
if (op.label === OPERATORS['!=']) {
return {
...op,
boost: 100,
};
}
return {
...op,
boost: 0,
};
});
}
}
}
// Add space after selection for operators
const optionsWithSpace = addSpaceToOptions(options);
return {
from: word?.from ?? 0,
to: word?.to ?? cursorPos.ch,
options: optionsWithSpace,
};
}
if (queryContext.isInValue) {
// Fetch values based on the key - use available context
const keyName = queryContext.keyToken || queryContext.currentPair?.key || '';
const operatorName =
queryContext.operatorToken || queryContext.currentPair?.operator || '';
if (!keyName) {
return null;
}
let searchText = '';
if (queryContext.currentPair && queryContext.currentPair.value) {
searchText = unquote(queryContext.currentPair.value).toLowerCase().trim();
}
options = (valueSuggestions || []).filter((option) =>
option.label.toLowerCase().includes(searchText),
);
// Trigger fetch only if needed
const shouldFetch =
// Fetch only if key is available
keyName &&
// Fetch if either there's no suggestion left with the current searchText or searchText is empty
(((options.length === 0 || searchText === '') &&
lastValueRef.current !== searchText &&
!isFetchingCompleteValuesList) ||
keyName !== activeKey ||
isLoadingSuggestions) &&
!(isLoadingSuggestions && lastKeyRef.current === keyName);
if (shouldFetch) {
// eslint-disable-next-line sonarjs/no-identical-functions
debouncedFetchValueSuggestions({
key: keyName,
searchText,
fetchingComplete: true,
});
}
// Process options to add appropriate formatting when selected
const processedOptions = options.map((option) => {
// Clone the option to avoid modifying the original
const processedOption = { ...option };
// Skip processing for non-selectable items
if (!option.apply || typeof option.apply === 'function') {
return option;
}
// Format values based on their type and the operator
if (option.type === 'value' || option.type === 'keyword') {
// String values get quoted
processedOption.apply = formatValueForOperator(
option.label,
operatorName,
option.type,
);
} else if (option.type === 'number') {
// Numbers don't get quoted but may need brackets for IN operators
if (isListOperator(operatorName)) {
processedOption.apply = `[${option.label}]`;
} else {
processedOption.apply = option.label;
}
} else if (option.type === 'boolean') {
// Boolean values don't get quoted
processedOption.apply = option.label;
} else if (option.type === 'array') {
// Arrays are already formatted as arrays
processedOption.apply = option.label;
}
return processedOption;
});
// Add space after selection
const optionsWithSpace = addSpaceToOptions(processedOptions);
// Return current value suggestions from state
return {
from: word?.from ?? 0,
options: optionsWithSpace,
};
}
if (queryContext.isInFunction) {
options = [
{ label: 'HAS', type: 'function' },
{ label: 'HASANY', type: 'function' },
{ label: 'HASALL', type: 'function' },
];
// Add space after selection for functions
const optionsWithSpace = addSpaceToOptions(options);
return {
from: word?.from ?? 0,
options: optionsWithSpace,
};
}
if (queryContext.isInConjunction) {
options = [
{ label: 'AND', type: 'conjunction' },
{ label: 'OR', type: 'conjunction' },
];
// Add space after selection for conjunctions
const optionsWithSpace = addSpaceToOptions(options);
return {
from: word?.from ?? 0,
options: optionsWithSpace,
};
}
if (queryContext.isInParenthesis) {
// Different suggestions based on the context within parenthesis or bracket
const curChar = query.charAt(cursorPos.ch - 1) || '';
if (curChar === '(' || curChar === '[') {
// Right after opening parenthesis/bracket
if (curChar === '(') {
// In expression context, suggest keys, functions, or nested parentheses
options = [
...(keySuggestions || []),
{ label: '(', type: 'parenthesis', info: 'Open nested group' },
{ label: 'NOT', type: 'operator', info: 'Negate expression' },
...options.filter((opt) => opt.type === 'function'),
];
// Add space after selection for opening parenthesis context
const optionsWithSpace = addSpaceToOptions(options);
return {
from: word?.from ?? 0,
options: optionsWithSpace,
};
}
// Inside square brackets (likely for IN operator)
// Suggest values, commas, or closing bracket
return {
from: word?.from ?? 0,
options: valueSuggestions,
};
}
if (curChar === ')' || curChar === ']') {
// After closing parenthesis/bracket, suggest conjunctions
options = [
{ label: 'AND', type: 'conjunction' },
{ label: 'OR', type: 'conjunction' },
];
// Add space after selection for closing parenthesis context
const optionsWithSpace = addSpaceToOptions(options);
return {
from: word?.from ?? 0,
options: optionsWithSpace,
};
}
}
// Don't show anything if no context detected
return {
from: word?.from ?? 0,
options: [],
};
}
// Effect to handle focus state and trigger suggestions
useEffect(() => {
const clearTimeout = toggleSuggestions(10);
return (): void => clearTimeout();
}, [isFocused, toggleSuggestions]);
useEffect(() => {
if (!queryContext) return;
// Trigger suggestions based on context
if (editorRef.current) {
toggleSuggestions(10);
}
// Handle value suggestions for value context
if (queryContext.isInValue) {
const { keyToken, currentToken } = queryContext;
const key = keyToken || currentToken;
// Only fetch if needed and if we have a valid key
if (key && key !== activeKey && !isLoadingSuggestions) {
fetchValueSuggestions({ key });
}
}
}, [
queryContext,
toggleSuggestions,
isLoadingSuggestions,
activeKey,
fetchValueSuggestions,
]);
const getTooltipContent = (): JSX.Element => (
<div>
Need help with search syntax?
<br />
<a
href="https://signoz.io/docs/userguide/search-syntax/"
target="_blank"
rel="noopener noreferrer"
style={{ color: '#1890ff', textDecoration: 'underline' }}
>
View documentation
</a>
</div>
);
return (
<div className="code-mirror-where-clause">
{editingMode && (
<div className={`context-indicator context-indicator-${editingMode}`}>
Currently editing: {renderContextBadge()}
{queryContext?.keyToken && (
<span className="triplet-info">
Key: <Tag>{queryContext.keyToken}</Tag>
</span>
)}
{queryContext?.operatorToken && (
<span className="triplet-info">
Operator: <Tag>{queryContext.operatorToken}</Tag>
</span>
)}
{queryContext?.valueToken && (
<span className="triplet-info">
Value: <Tag>{queryContext.valueToken}</Tag>
</span>
)}
{queryContext?.currentPair && (
<span className="triplet-info query-pair-info">
Current pair: <Tag color="blue">{queryContext.currentPair.key}</Tag>
<Tag color="purple">{queryContext.currentPair.operator}</Tag>
{queryContext.currentPair.value && (
<Tag color="green">{queryContext.currentPair.value}</Tag>
)}
<Tag color={queryContext.currentPair.isComplete ? 'success' : 'warning'}>
{queryContext.currentPair.isComplete ? 'Complete' : 'Incomplete'}
</Tag>
</span>
)}
{queryContext?.queryPairs && queryContext.queryPairs.length > 0 && (
<span className="triplet-info">
Total pairs: <Tag color="blue">{queryContext.queryPairs.length}</Tag>
</span>
)}
</div>
)}
<div className="query-where-clause-editor-container">
<Tooltip title={getTooltipContent()} placement="left">
<a
href="https://signoz.io/docs/userguide/search-syntax/"
target="_blank"
rel="noopener noreferrer"
style={{
position: 'absolute',
top: 8,
right: validation.isValid === false && query ? 40 : 8, // Move left when error shown
cursor: 'help',
zIndex: 10,
transition: 'right 0.2s ease',
display: 'inline-flex',
alignItems: 'center',
color: '#8c8c8c',
}}
onClick={(e): void => e.stopPropagation()}
>
<Info
size={14}
style={{ opacity: 0.9, color: isDarkMode ? '#ffffff' : '#000000' }}
/>
</a>
</Tooltip>
<CodeMirror
value={query}
theme={isDarkMode ? copilot : githubLight}
onChange={handleChange}
onUpdate={handleUpdate}
className={cx('query-where-clause-editor', {
isValid: validation.isValid === true,
hasErrors: validation.errors.length > 0,
})}
extensions={[
autocompletion({
override: [autoSuggestions],
defaultKeymap: true,
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 50,
}),
javascript({ jsx: false, typescript: false }),
EditorView.lineWrapping,
stopEventsExtension,
Prec.highest(
keymap.of([
...completionKeymap,
{
key: 'Escape',
run: closeCompletion,
},
{
key: 'Enter',
preventDefault: true,
// Prevent default behavior of Enter to add new line
// and instead run a custom action
run: (): boolean => true,
},
{
key: 'Mod-Enter',
preventDefault: true,
// Prevent default behavior of Mod-Enter to add new line
// and instead run a custom action
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
onRun(query);
} else {
handleRunQuery(true, true);
}
return true;
},
},
{
key: 'Shift-Enter',
preventDefault: true,
// Prevent default behavior of Shift-Enter to add new line
run: (): boolean => true,
},
]),
),
]}
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
basicSetup={{
lineNumbers: false,
}}
onFocus={(): void => {
setIsFocused(true);
}}
onBlur={handleBlur}
/>
{query && validation.isValid === false && !isFocused && (
<div
className={cx('query-status-container', {
hasErrors: validation.errors.length > 0,
})}
>
<Popover
placement="bottomRight"
showArrow={false}
content={
<div className="query-status-content">
<div className="query-status-content-header">
<div className="query-validation">
<div className="query-validation-errors">
{validation.errors.map((error) => (
<div key={error.message} className="query-validation-error">
<div className="query-validation-error">
{error.line}:{error.column} - {error.message}
</div>
</div>
))}
</div>
</div>
</div>
</div>
}
overlayClassName="query-status-popover"
>
{validation.isValid ? (
<Button
type="text"
icon={<CheckCircleFilled />}
className="periscope-btn ghost"
/>
) : (
<Button
type="text"
icon={<TriangleAlert size={14} color={Color.BG_CHERRY_500} />}
className="periscope-btn ghost"
/>
)}
</Popover>
</div>
)}
</div>
{showExamples && (
<Card size="small" className="query-examples-card">
<Collapse
ghost
size="small"
className="query-examples"
defaultActiveKey={[]}
>
<Panel header="Query Examples" key="1">
<div className="query-examples-list">
{queryExamples.map((example) => (
<div
className="query-example-content"
key={example.label}
onClick={(): void => handleExampleClick(example.query)}
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
handleExampleClick(example.query);
}
}}
>
<CodeMirror
value={example.query}
theme={isDarkMode ? copilot : githubLight}
extensions={[
javascript({ jsx: false, typescript: false }),
EditorView.editable.of(false),
]}
basicSetup={{ lineNumbers: false }}
className="query-example-code-mirror"
/>
</div>
))}
</div>
</Panel>
</Collapse>
</Card>
)}
{/* {queryContext && (
<Card size="small" title="Current Context" className="query-context">
<div className="context-details">
<Space direction="vertical" size={4}>
<Space>
<Typography.Text strong>Token:</Typography.Text>
<Typography.Text code>
{queryContext.currentToken || '-'}
</Typography.Text>
</Space>
<Space>
<Typography.Text strong>Type:</Typography.Text>
<Typography.Text>{queryContext.tokenType || '-'}</Typography.Text>
</Space>
<Space>
<Typography.Text strong>Context:</Typography.Text>
{renderContextBadge()}
</Space>
{queryContext.keyToken && (
<Space>
<Typography.Text strong>Key:</Typography.Text>
<Typography.Text code>{queryContext.keyToken}</Typography.Text>
</Space>
)}
{queryContext.operatorToken && (
<Space>
<Typography.Text strong>Operator:</Typography.Text>
<Typography.Text code>{queryContext.operatorToken}</Typography.Text>
</Space>
)}
{queryContext.valueToken && (
<Space>
<Typography.Text strong>Value:</Typography.Text>
<Typography.Text code>{queryContext.valueToken}</Typography.Text>
</Space>
)}
</Space>
</div>
</Card>
)} */}
</div>
);
}
QuerySearch.defaultProps = {
onRun: undefined,
signalSource: '',
};
export default QuerySearch;