From bea5c4386af8da6eaa250e17009f72f45d236feb Mon Sep 17 00:00:00 2001 From: Yunus M Date: Thu, 12 Jun 2025 18:21:33 +0530 Subject: [PATCH] feat: handle having option autocomplete ux --- .../QueryBuilderV2/QueryBuilderV2.styles.scss | 1 + .../QueryAddOns/HavingFilter/HavingFilter.tsx | 58 ++-- .../QueryAddOns/QueryAddOns.styles.scss | 167 ++++++++++ .../QueryAggregation.styles.scss | 315 +++++++++--------- .../QueryAggregation/QueryAggregation.tsx | 34 +- .../QueryAggregationSelect.tsx | 69 +++- .../TracesExplorer/QuerySection/index.tsx | 26 +- 7 files changed, 451 insertions(+), 219 deletions(-) diff --git a/frontend/src/components/QueryBuilderV2/QueryBuilderV2.styles.scss b/frontend/src/components/QueryBuilderV2/QueryBuilderV2.styles.scss index 03b4a57a6c4a..c8b52b5fcb84 100644 --- a/frontend/src/components/QueryBuilderV2/QueryBuilderV2.styles.scss +++ b/frontend/src/components/QueryBuilderV2/QueryBuilderV2.styles.scss @@ -17,6 +17,7 @@ .qb-content-container { display: flex; flex-direction: column; + width: calc(100% - 44px); flex: 1; diff --git a/frontend/src/components/QueryBuilderV2/QueryV2/QueryAddOns/HavingFilter/HavingFilter.tsx b/frontend/src/components/QueryBuilderV2/QueryV2/QueryAddOns/HavingFilter/HavingFilter.tsx index 689e5884c35e..de2769fea1a4 100644 --- a/frontend/src/components/QueryBuilderV2/QueryV2/QueryAddOns/HavingFilter/HavingFilter.tsx +++ b/frontend/src/components/QueryBuilderV2/QueryV2/QueryAddOns/HavingFilter/HavingFilter.tsx @@ -121,10 +121,10 @@ function HavingFilter({ onClose }: { onClose: () => void }): JSX.Element { const isAfterOperator = (tokens: string[]): boolean => { if (tokens.length === 0) return false; const lastToken = tokens[tokens.length - 1]; - // Check if the last token ends with any operator (with or without space) + // Check if the last token is exactly an operator or ends with an operator and space return havingOperators.some((op) => { const opWithSpace = `${op.value} `; - return lastToken.endsWith(op.value) || lastToken.endsWith(opWithSpace); + return lastToken === op.value || lastToken.endsWith(opWithSpace); }); }; @@ -152,6 +152,21 @@ function HavingFilter({ onClose }: { onClose: () => void }): JSX.Element { }; } + // Show value suggestions after operator - this should take precedence + if (isAfterOperator(tokens)) { + return { + from: context.pos, + options: [ + ...commonValues, + { + label: 'Enter a custom number value', + type: 'text', + apply: (): boolean => true, + }, + ], + }; + } + // Suggest key/operator pairs and ( for grouping if ( tokens.length === 0 || @@ -164,21 +179,18 @@ function HavingFilter({ onClose }: { onClose: () => void }): JSX.Element { }; } - // Show value suggestions after operator - if (isAfterOperator(tokens)) { - return { - from: context.pos, - options: [ - ...commonValues, - { - label: 'Enter a custom number value', - type: 'text', - apply: (): boolean => - // Don't insert any text, just let the user type - true, - }, - ], - }; + // Show suggestions when typing + if (tokens.length > 0) { + const lastToken = tokens[tokens.length - 1]; + const filteredOptions = options.filter((opt) => + opt.label.toLowerCase().includes(lastToken.toLowerCase()), + ); + if (filteredOptions.length > 0) { + return { + from: context.pos - lastToken.length, + options: filteredOptions, + }; + } } // Suggest ) for grouping after a value and a space, if there are unmatched ( @@ -205,12 +217,16 @@ function HavingFilter({ onClose }: { onClose: () => void }): JSX.Element { }; } - return null; + // Show all options if no other condition matches + return { + from: context.pos, + options, + }; }, ], defaultKeymap: true, closeOnBlur: false, - maxRenderedOptions: 50, + maxRenderedOptions: 200, activateOnTyping: true, }), [options], @@ -218,11 +234,11 @@ function HavingFilter({ onClose }: { onClose: () => void }): JSX.Element { return (
-
+
- +
+ - {showAggregationInterval && ( -
-
every
-
- {}} - /> + {showAggregationInterval && ( +
+
every
+
+ {}} + /> +
-
- )} + )} +
); } diff --git a/frontend/src/components/QueryBuilderV2/QueryV2/QueryAggregation/QueryAggregationSelect.tsx b/frontend/src/components/QueryBuilderV2/QueryV2/QueryAggregation/QueryAggregationSelect.tsx index 3d836df650e0..6e6c6fdfb859 100644 --- a/frontend/src/components/QueryBuilderV2/QueryV2/QueryAggregation/QueryAggregationSelect.tsx +++ b/frontend/src/components/QueryBuilderV2/QueryV2/QueryAggregation/QueryAggregationSelect.tsx @@ -13,6 +13,7 @@ import { CompletionContext, completionKeymap, CompletionResult, + startCompletion, } from '@codemirror/autocomplete'; import { javascript } from '@codemirror/lang-javascript'; import { RangeSetBuilder } from '@codemirror/state'; @@ -28,7 +29,7 @@ import { getAggregateAttribute } from 'api/queryBuilder/getAggregateAttribute'; import { QueryBuilderKeys } from 'constants/queryBuilder'; import { tracesAggregateOperatorOptions } from 'constants/queryBuilderOperators'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useQuery } from 'react-query'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { TracesAggregatorOperator } from 'types/common/queryBuilder'; @@ -122,6 +123,16 @@ function QueryAggregationSelect(): JSX.Element { { func: string; arg: string }[] >([]); const editorRef = useRef(null); + const [isFocused, setIsFocused] = useState(false); + + // Helper function to safely start completion + const safeStartCompletion = useCallback((): void => { + requestAnimationFrame(() => { + if (editorRef.current) { + startCompletion(editorRef.current); + } + }); + }, []); // Update cursor position on every editor update const handleUpdate = (update: { view: EditorView }): void => { @@ -129,6 +140,13 @@ function QueryAggregationSelect(): JSX.Element { setCursorPos(pos); }; + // Effect to handle focus state and trigger suggestions + useEffect(() => { + if (isFocused) { + safeStartCompletion(); + } + }, [isFocused, safeStartCompletion]); + // Extract all valid function-argument pairs from the input useEffect(() => { const pairs: { func: string; arg: string }[] = []; @@ -245,6 +263,11 @@ function QueryAggregationSelect(): JSX.Element { changes: { from, to, insert: insertText }, selection: { anchor: cursorPos }, }); + + // Trigger suggestions after a small delay + setTimeout(() => { + safeStartCompletion(); + }, 50); }, }), ); @@ -263,14 +286,20 @@ function QueryAggregationSelect(): JSX.Element { from: number, to: number, ): void => { + // Insert the selected key followed by ') ' view.dispatch({ - changes: { from, to, insert: completion.label }, - selection: { anchor: from + completion.label.length }, + changes: { from, to, insert: `${completion.label}) ` }, + selection: { anchor: from + completion.label.length + 2 }, // Position cursor after ') ' }); + + // Trigger next suggestions after a small delay + setTimeout(() => { + safeStartCompletion(); + }, 50); }, }), ) || [], - [aggregateAttributeData], + [aggregateAttributeData, safeStartCompletion], ); const aggregatorAutocomplete = useMemo( @@ -349,21 +378,27 @@ function QueryAggregationSelect(): JSX.Element { }; } - // Before returning operatorCompletions, filter out 'count' if already present in the input (case-insensitive, direct text check) + // Show operator suggestions if no function context or not accepting args if (!funcName || !operatorArgMeta[funcName]?.acceptsArgs) { // Check if 'count(' is present in the current input (case-insensitive) const hasCount = text.toLowerCase().includes('count('); const availableOperators = hasCount ? operatorCompletions.filter((op) => op.label.toLowerCase() !== 'count') : operatorCompletions; + + // Get the word before cursor if any const word = context.matchBefore(/[\w\d_]+/); - if (!word && !context.explicit) { - return null; + + // Show suggestions if: + // 1. There's a word match + // 2. The input is empty (cursor at start) + // 3. The user explicitly triggered completion + if (word || cursorPos === 0 || context.explicit) { + return { + from: word ? word.from : cursorPos, + options: availableOperators, + }; } - return { - from: word ? word.from : context.pos, - options: availableOperators, - }; } return null; @@ -383,7 +418,6 @@ function QueryAggregationSelect(): JSX.Element { value={input} onChange={setInput} className="query-aggregation-select-editor" - width="100%" theme={copilot} extensions={[ chipPlugin, @@ -404,7 +438,16 @@ function QueryAggregationSelect(): JSX.Element { completionKeymap: true, }} onUpdate={handleUpdate} - ref={editorRef} + onCreateEditor={(view: EditorView): void => { + editorRef.current = view; + }} + onFocus={(): void => { + setIsFocused(true); + safeStartCompletion(); + }} + onBlur={(): void => { + setIsFocused(false); + }} />
); diff --git a/frontend/src/container/TracesExplorer/QuerySection/index.tsx b/frontend/src/container/TracesExplorer/QuerySection/index.tsx index 4b957b67751c..b9b90d2d1023 100644 --- a/frontend/src/container/TracesExplorer/QuerySection/index.tsx +++ b/frontend/src/container/TracesExplorer/QuerySection/index.tsx @@ -7,8 +7,6 @@ import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQ import { memo, useCallback, useMemo } from 'react'; import { DataSource } from 'types/common/queryBuilder'; -import { Container } from './styles'; - function QuerySection(): JSX.Element { const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST); @@ -40,19 +38,17 @@ function QuerySection(): JSX.Element { }, [panelTypes, renderOrderBy]); return ( - - - + ); }