diff --git a/frontend/src/components/QueryBuilderV2/QueryAddOns/HavingFilter/HavingFilter.tsx b/frontend/src/components/QueryBuilderV2/QueryAddOns/HavingFilter/HavingFilter.tsx index 630966870242..a6ad584fd910 100644 --- a/frontend/src/components/QueryBuilderV2/QueryAddOns/HavingFilter/HavingFilter.tsx +++ b/frontend/src/components/QueryBuilderV2/QueryAddOns/HavingFilter/HavingFilter.tsx @@ -1,6 +1,15 @@ -import { Button, Select } from 'antd'; +import { + autocompletion, + closeCompletion, + CompletionContext, + completionKeymap, + CompletionResult, +} from '@codemirror/autocomplete'; +import { javascript } from '@codemirror/lang-javascript'; +import { copilot } from '@uiw/codemirror-theme-copilot'; +import CodeMirror, { EditorView, keymap } from '@uiw/react-codemirror'; import { useQueryBuilderV2Context } from 'components/QueryBuilderV2/QueryBuilderV2Context'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; const havingOperators = [ { @@ -37,14 +46,29 @@ const havingOperators = [ }, ]; -function HavingFilter({ onClose }: { onClose: () => void }): JSX.Element { +const conjunctions = [ + { label: 'AND', value: 'AND' }, + { label: 'OR', value: 'OR' }, +]; + +const openBrace = { label: '(', value: '(' }; +const closeBrace = { label: ')', value: ')' }; + +function HavingFilter(): JSX.Element { const { aggregationOptions } = useQueryBuilderV2Context(); + const [input, setInput] = useState(''); - const [selectedHavingOptions, setSelectedHavingOptions] = useState( - [], - ); + const [cursorPos, setCursorPos] = useState(0); - console.log('selectedHavingOptions', selectedHavingOptions); + const editorRef = useRef(null); + + console.log('cursorPos', cursorPos); + + // Update cursor position on every editor update + const handleUpdate = (update: { view: EditorView }): void => { + const pos = update.view.state.selection.main.from; + setCursorPos(pos); + }; const [options, setOptions] = useState<{ label: string; value: string }[]>([]); @@ -58,8 +82,8 @@ function HavingFilter({ onClose }: { onClose: () => void }): JSX.Element { const operator = havingOperators[j]; options.push({ - label: `${opt.func}(${opt.arg}) ${operator.label}`, - value: `${opt.func}(${opt.arg}) ${operator.label}`, + label: `${opt.func}(${opt.arg}) ${operator.label} `, + value: `${opt.func}(${opt.arg}) ${operator.label} `, }); } } @@ -67,21 +91,101 @@ function HavingFilter({ onClose }: { onClose: () => void }): JSX.Element { setOptions(options); }, [aggregationOptions]); - console.log('options', options); + // Helper to check if a string is a number + const isNumber = (token: string): boolean => /^-?\d+(\.\d+)?$/.test(token); + + const havingAutocomplete = useMemo(() => { + const isKeyOperator = (token: string): boolean => + options.some((opt) => token.startsWith(opt.value)); + + // Helper to count standalone ( and ) for grouping + const countGroupingBraces = ( + input: string, + ): { openCount: number; closeCount: number } => { + // Remove aggregator function calls (e.g., sum(duration)) + const withoutFuncs = input.replace(/\w+\([^)]*\)/g, ''); + const openCount = (withoutFuncs.match(/\(/g) || []).length; + const closeCount = (withoutFuncs.match(/\)/g) || []).length; + return { openCount, closeCount }; + }; + + return autocompletion({ + override: [ + (context: CompletionContext): CompletionResult | null => { + const text = context.state.sliceDoc(0, context.pos); + const trimmedText = text.trim(); + const tokens = trimmedText.split(/\s+/).filter(Boolean); + const { openCount, closeCount } = countGroupingBraces(text); + + // Suggest key/operator pairs and ( for grouping + if ( + tokens.length === 0 || + conjunctions.some((c) => tokens[tokens.length - 1] === c.value) || + tokens[tokens.length - 1] === '(' + ) { + return { + from: context.pos, + options: [openBrace, ...options], + }; + } + if (isKeyOperator(tokens[tokens.length - 1])) { + return { + from: context.pos, + options: [{ label: 'Enter a number value', type: 'text', apply: '' }], + }; + } + // Suggest ) for grouping after a value and a space, if there are unmatched ( + if ( + tokens.length > 0 && + isNumber(tokens[tokens.length - 1]) && + text.endsWith(' ') + ) { + return { + from: context.pos, + options: + openCount > closeCount ? [closeBrace, ...conjunctions] : conjunctions, + }; + } + return null; + }, + ], + defaultKeymap: true, + closeOnBlur: false, + maxRenderedOptions: 50, + activateOnTyping: true, + }); + }, [options]); return (
-