/* 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(queryData.filter?.expression || ''); const [valueSuggestions, setValueSuggestions] = useState([]); const [activeKey, setActiveKey] = useState(''); const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); const [queryContext, setQueryContext] = useState(null); const [validation, setValidation] = useState({ 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(''); 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(false); const lastPosRef = useRef<{ line: number; ch: number }>({ line: 0, ch: 0 }); // Reference to the editor view for programmatic autocompletion const editorRef = useRef(null); const lastKeyRef = useRef(''); const lastFetchedKeyRef = useRef(''); const lastValueRef = useRef(''); const isMountedRef = useRef(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 => { 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(); 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 => { 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 Unknown; switch (editingMode) { case 'key': return Key; case 'operator': return Operator; case 'value': return Value; case 'conjunction': return Conjunction; case 'function': return Function; case 'parenthesis': return Parenthesis; case 'bracketList': return Bracket List; default: return Unknown; } }; // 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 => (
Need help with search syntax?
View documentation
); return (
{editingMode && (
Currently editing: {renderContextBadge()} {queryContext?.keyToken && ( Key: {queryContext.keyToken} )} {queryContext?.operatorToken && ( Operator: {queryContext.operatorToken} )} {queryContext?.valueToken && ( Value: {queryContext.valueToken} )} {queryContext?.currentPair && ( Current pair: {queryContext.currentPair.key} {queryContext.currentPair.operator} {queryContext.currentPair.value && ( {queryContext.currentPair.value} )} {queryContext.currentPair.isComplete ? 'Complete' : 'Incomplete'} )} {queryContext?.queryPairs && queryContext.queryPairs.length > 0 && ( Total pairs: {queryContext.queryPairs.length} )}
)}
e.stopPropagation()} > 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 && (
0, })} >
{validation.errors.map((error) => (
{error.line}:{error.column} - {error.message}
))}
} overlayClassName="query-status-popover" > {validation.isValid ? (
)}
{showExamples && (
{queryExamples.map((example) => (
handleExampleClick(example.query)} role="button" tabIndex={0} onKeyDown={(e): void => { if (e.key === 'Enter' || e.key === ' ') { handleExampleClick(example.query); } }} >
))}
)} {/* {queryContext && (
Token: {queryContext.currentToken || '-'} Type: {queryContext.tokenType || '-'} Context: {renderContextBadge()} {queryContext.keyToken && ( Key: {queryContext.keyToken} )} {queryContext.operatorToken && ( Operator: {queryContext.operatorToken} )} {queryContext.valueToken && ( Value: {queryContext.valueToken} )}
)} */} ); } QuerySearch.defaultProps = { onRun: undefined, signalSource: '', }; export default QuerySearch;