diff --git a/frontend/src/components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch.tsx b/frontend/src/components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch.tsx index 24a66c3e630d..aab2cd2d9133 100644 --- a/frontend/src/components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch.tsx +++ b/frontend/src/components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch.tsx @@ -93,7 +93,7 @@ function QuerySearch({ onRun?: (query: string) => void; }): JSX.Element { const isDarkMode = useIsDarkMode(); - const [query, setQuery] = useState(queryData.filter?.expression || ''); + const [query, setQuery] = useState(''); const [valueSuggestions, setValueSuggestions] = useState([]); const [activeKey, setActiveKey] = useState(''); const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); @@ -104,6 +104,10 @@ function QuerySearch({ errors: [], }); + const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 }); + const [isFocused, setIsFocused] = useState(false); + const [hasInteractedWithQB, setHasInteractedWithQB] = useState(false); + const handleQueryValidation = (newQuery: string): void => { try { const validationResponse = validateQuery(newQuery); @@ -123,13 +127,28 @@ function QuerySearch({ useEffect(() => { const newQuery = queryData.filter?.expression || ''; - // Only mark as external change if the query actually changed from external source + // Only update query from external source when editor is not focused + // When focused, just update the lastExternalQuery to track changes if (newQuery !== lastExternalQuery) { setQuery(newQuery); setIsExternalQueryChange(true); setLastExternalQuery(newQuery); } - }, [queryData.filter?.expression, lastExternalQuery]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [queryData.filter?.expression]); + + useEffect(() => { + // Update the query when the editor is blurred and the query has changed + // Only call onChange if the editor has been focused before (not on initial mount) + if ( + !isFocused && + hasInteractedWithQB && + query !== queryData.filter?.expression + ) { + onChange(query); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isFocused]); // Validate query when it changes externally (from queryData) useEffect(() => { @@ -145,9 +164,6 @@ function QuerySearch({ const [showExamples] = useState(false); - const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 }); - const [isFocused, setIsFocused] = useState(false); - const [ isFetchingCompleteValuesList, setIsFetchingCompleteValuesList, @@ -161,6 +177,9 @@ function QuerySearch({ const lastFetchedKeyRef = useRef(''); const lastValueRef = useRef(''); const isMountedRef = useRef(true); + const [shouldRunQueryPostUpdate, setShouldRunQueryPostUpdate] = useState( + false, + ); const { handleRunQuery } = useQueryBuilder(); @@ -206,6 +225,7 @@ function QuerySearch({ return (): void => clearTimeout(timeoutId); }, + // eslint-disable-next-line react-hooks/exhaustive-deps [isFocused], ); @@ -545,7 +565,6 @@ function QuerySearch({ 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 @@ -1209,6 +1228,25 @@ function QuerySearch({ ); + // Effect to handle query run after update + useEffect( + () => { + // Only run the query post updating the filter expression. + // This runs the query in the next update cycle of react, when it's guaranteed that the query is updated. + // Because both the things are sequential and react batches the updates so it was still taking the old query. + if (shouldRunQueryPostUpdate) { + if (onRun && typeof onRun === 'function') { + onRun(query); + } else { + handleRunQuery(); + } + setShouldRunQueryPostUpdate(false); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [shouldRunQueryPostUpdate, handleRunQuery, onRun], + ); + return (
{editingMode && ( @@ -1283,6 +1321,7 @@ function QuerySearch({ theme={isDarkMode ? copilot : githubLight} onChange={handleChange} onUpdate={handleUpdate} + data-testid="query-where-clause-editor" className={cx('query-where-clause-editor', { isValid: validation.isValid === true, hasErrors: validation.errors.length > 0, @@ -1319,11 +1358,14 @@ function QuerySearch({ // 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(); + if ( + onChange && + typeof onChange === 'function' && + query !== queryData.filter?.expression + ) { + onChange(query); } + setShouldRunQueryPostUpdate(true); return true; }, }, @@ -1342,8 +1384,13 @@ function QuerySearch({ }} onFocus={(): void => { setIsFocused(true); + setHasInteractedWithQB(true); }} onBlur={handleBlur} + onCreateEditor={(view: EditorView): EditorView => { + editorRef.current = view; + return view; + }} /> {query && validation.isValid === false && !isFocused && ( diff --git a/frontend/src/components/QueryBuilderV2/QueryV2/__tests__/QuerySearch.test.tsx b/frontend/src/components/QueryBuilderV2/QueryV2/__tests__/QuerySearch.test.tsx new file mode 100644 index 000000000000..b3862283f6b3 --- /dev/null +++ b/frontend/src/components/QueryBuilderV2/QueryV2/__tests__/QuerySearch.test.tsx @@ -0,0 +1,380 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable sonarjs/cognitive-complexity */ +/* eslint-disable import/named */ +import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions'; +import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion'; +import { initialQueriesMap } from 'constants/queryBuilder'; +import * as UseQBModule from 'hooks/queryBuilder/useQueryBuilder'; +import React from 'react'; +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; +import type { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types'; +import { DataSource } from 'types/common/queryBuilder'; + +import QuerySearch from '../QuerySearch/QuerySearch'; + +jest.mock('hooks/useDarkMode', () => ({ + useIsDarkMode: (): boolean => false, +})); + +jest.mock('providers/Dashboard/Dashboard', () => ({ + useDashboard: (): { selectedDashboard: undefined } => ({ + selectedDashboard: undefined, + }), +})); + +jest.mock('hooks/queryBuilder/useQueryBuilder', () => { + const handleRunQuery = jest.fn(); + return { + __esModule: true, + useQueryBuilder: (): { handleRunQuery: () => void } => ({ handleRunQuery }), + handleRunQuery, + }; +}); + +jest.mock('@codemirror/autocomplete', () => ({ + autocompletion: (): Record => ({}), + closeCompletion: (): boolean => true, + completionKeymap: [] as unknown[], + startCompletion: (): boolean => true, +})); + +jest.mock('@codemirror/lang-javascript', () => ({ + javascript: (): Record => ({}), +})); + +jest.mock('@uiw/codemirror-theme-copilot', () => ({ + copilot: {}, +})); + +jest.mock('@uiw/codemirror-theme-github', () => ({ + githubLight: {}, +})); +jest.mock('api/querySuggestions/getKeySuggestions', () => ({ + getKeySuggestions: jest.fn().mockResolvedValue({ + data: { + data: { keys: {} as Record }, + }, + }), +})); + +jest.mock('api/querySuggestions/getValueSuggestion', () => ({ + getValueSuggestions: jest.fn().mockResolvedValue({ + data: { data: { values: { stringValues: [], numberValues: [] } } }, + }), +})); + +// Mock CodeMirror to a simple textarea to make it testable and call onUpdate +jest.mock( + '@uiw/react-codemirror', + (): Record => { + // Minimal EditorView shape used by the component + class EditorViewMock {} + (EditorViewMock as any).domEventHandlers = (): unknown => ({} as unknown); + (EditorViewMock as any).lineWrapping = {} as unknown; + (EditorViewMock as any).editable = { of: () => ({}) } as unknown; + + const keymap = { of: (arr: unknown) => arr } as unknown; + const Prec = { highest: (ext: unknown) => ext } as unknown; + + type CodeMirrorProps = { + value?: string; + onChange?: (v: string) => void; + onFocus?: () => void; + onBlur?: () => void; + placeholder?: string; + onCreateEditor?: (view: unknown) => unknown; + onUpdate?: (arg: { + view: { + state: { + selection: { main: { head: number } }; + doc: { + toString: () => string; + lineAt: ( + _pos: number, + ) => { number: number; from: number; to: number; text: string }; + }; + }; + }; + }) => void; + 'data-testid'?: string; + extensions?: unknown[]; + }; + + function CodeMirrorMock({ + value, + onChange, + onFocus, + onBlur, + placeholder, + onCreateEditor, + onUpdate, + 'data-testid': dataTestId, + extensions, + }: CodeMirrorProps): JSX.Element { + const [localValue, setLocalValue] = React.useState(value ?? ''); + + // Provide a fake editor instance + React.useEffect(() => { + if (onCreateEditor) { + onCreateEditor(new EditorViewMock() as any); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Call onUpdate whenever localValue changes to simulate cursor and doc + React.useEffect(() => { + if (onUpdate) { + const text = String(localValue ?? ''); + const head = text.length; + onUpdate({ + view: { + state: { + selection: { main: { head } }, + doc: { + toString: (): string => text, + lineAt: () => ({ + number: 1, + from: 0, + to: text.length, + text, + }), + }, + }, + }, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [localValue]); + + const handleKeyDown = ( + e: React.KeyboardEvent, + ): void => { + const isModEnter = e.key === 'Enter' && (e.metaKey || e.ctrlKey); + if (!isModEnter) return; + const exts: unknown[] = Array.isArray(extensions) ? extensions : []; + const flat: unknown[] = exts.flatMap((x: unknown) => + Array.isArray(x) ? x : [x], + ); + const keyBindings = flat.filter( + (x) => + Boolean(x) && + typeof x === 'object' && + 'key' in (x as Record), + ) as Array<{ key?: string; run?: () => boolean | void }>; + keyBindings + .filter((b) => b.key === 'Mod-Enter' && typeof b.run === 'function') + .forEach((b) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + b.run!(); + }); + }; + + return ( +