feat: handle having option autocomplete ux

This commit is contained in:
Yunus M 2025-06-12 18:21:33 +05:30 committed by ahrefabhi
parent 8622aebd0e
commit bea5c4386a
7 changed files with 451 additions and 219 deletions

View File

@ -17,6 +17,7 @@
.qb-content-container {
display: flex;
flex-direction: column;
width: calc(100% - 44px);
flex: 1;

View File

@ -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 (
<div className="having-filter-container">
<div className="query-aggregation-select-container">
<div className="having-filter-select-container">
<CodeMirror
value={input}
onChange={setInput}
className="query-aggregation-select-editor"
className="having-filter-select-editor"
width="100%"
theme={copilot}
extensions={[

View File

@ -57,6 +57,173 @@
}
}
.having-filter-container {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
.having-filter-select-container {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
.having-filter-select-editor {
border-radius: 2px;
flex: 1;
.cm-content {
padding: 0;
}
.cm-editor {
border-radius: 2px;
background-color: transparent !important;
position: relative !important;
&:focus-within {
border-color: var(--bg-robin-500);
}
.cm-content {
border-radius: 2px;
border: 1px solid var(--Slate-400, #1d212d);
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
padding: 0px !important;
background-color: #121317 !important;
&:focus-within {
border-color: var(--bg-ink-200);
}
}
.cm-tooltip-autocomplete {
background: var(--bg-ink-300) !important;
border-radius: 2px !important;
font-size: 12px !important;
font-weight: 500 !important;
margin-top: -2px !important;
min-width: 400px !important;
position: absolute !important;
top: 38px !important;
left: 0px !important;
border-radius: 4px;
border: 1px solid var(--bg-slate-200, #1d212d);
border-top: none !important;
border-top-left-radius: 0px !important;
border-top-right-radius: 0px !important;
background: linear-gradient(
139deg,
rgba(18, 19, 23, 0.8) 0%,
rgba(18, 19, 23, 0.9) 98.68%
) !important;
backdrop-filter: blur(20px);
box-sizing: border-box;
font-family: 'Space Mono', monospace !important;
ul {
width: 100% !important;
max-width: 100% !important;
font-family: 'Space Mono', monospace !important;
min-height: 200px !important;
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgb(136, 136, 136);
border-radius: 0.625rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
li {
width: 100% !important;
max-width: 100% !important;
line-height: 36px !important;
height: 36px !important;
padding: 4px 8px !important;
display: flex !important;
align-items: center !important;
gap: 8px !important;
box-sizing: border-box;
overflow: hidden;
font-family: 'Space Mono', monospace !important;
.cm-completionIcon {
display: none !important;
}
&[aria-selected='true'] {
// background-color: rgba(78, 116, 248, 0.7) !important;
background: rgba(171, 189, 255, 0.04) !important;
}
}
}
}
.cm-gutters {
display: none !important;
}
.cm-line {
line-height: 36px !important;
font-family: 'Space Mono', monospace !important;
background-color: #121317 !important;
::-moz-selection {
background: var(--bg-ink-100) !important;
opacity: 0.5 !important;
}
::selection {
background: var(--bg-ink-100) !important;
opacity: 0.5 !important;
}
.cm-function {
color: var(--bg-robin-500) !important;
}
.chip-decorator {
background: rgba(36, 40, 52, 1) !important;
color: var(--bg-vanilla-100) !important;
border-radius: 4px;
padding: 2px 4px;
margin-right: 4px;
}
}
.cm-selectionBackground {
background: var(--bg-ink-100) !important;
opacity: 0.5 !important;
}
}
}
.close-btn {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
height: 38px;
width: 38px;
border-left: transparent;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
}
}
.selected-add-ons-content {
display: grid;
grid-template-columns: repeat(2, 1fr);

View File

@ -1,189 +1,196 @@
.query-aggregation-container {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
display: block;
position: relative;
.query-aggregation-options-input {
width: 100%;
height: 36px;
line-height: 36px;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
font-family: 'Space Mono', monospace !important;
&::placeholder {
color: var(--bg-vanilla-100);
opacity: 0.5;
}
}
.query-aggregation-interval {
.aggregation-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
}
align-items: center;
.query-aggregation-select-container {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
.query-aggregation-options-input {
width: 100%;
height: 36px;
line-height: 36px;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
.query-aggregation-select-editor {
border-radius: 2px;
flex: 1;
font-family: 'Space Mono', monospace !important;
.cm-content {
padding: 0;
&::placeholder {
color: var(--bg-vanilla-100);
opacity: 0.5;
}
}
.cm-editor {
border-radius: 2px;
background-color: transparent !important;
position: relative !important;
.query-aggregation-interval {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
width: 400px;
}
&:focus-within {
border-color: var(--bg-robin-500);
}
.query-aggregation-select-container {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
flex: 1;
.cm-content {
.query-aggregation-select-editor {
border-radius: 2px;
border: 1px solid var(--Slate-400, #1d212d);
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
padding: 0px !important;
background-color: #121317 !important;
flex: 1;
&:focus-within {
border-color: var(--bg-ink-200);
.cm-content {
padding: 0;
}
}
.cm-tooltip-autocomplete {
background: var(--bg-ink-300) !important;
border-radius: 2px !important;
font-size: 12px !important;
font-weight: 500 !important;
margin-top: -2px !important;
min-width: 400px !important;
position: absolute !important;
top: 38px !important;
left: 0px !important;
.cm-editor {
border-radius: 2px;
background-color: transparent !important;
position: relative !important;
border-radius: 4px;
border: 1px solid var(--bg-slate-200, #1d212d);
border-top: none !important;
border-top-left-radius: 0px !important;
border-top-right-radius: 0px !important;
background: linear-gradient(
139deg,
rgba(18, 19, 23, 0.8) 0%,
rgba(18, 19, 23, 0.9) 98.68%
) !important;
backdrop-filter: blur(20px);
box-sizing: border-box;
font-family: 'Space Mono', monospace !important;
ul {
width: 100% !important;
max-width: 100% !important;
font-family: 'Space Mono', monospace !important;
min-height: 200px !important;
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgb(136, 136, 136);
border-radius: 0.625rem;
}
&::-webkit-scrollbar-track {
background: transparent;
&:focus-within {
border-color: var(--bg-robin-500);
}
li {
width: 100% !important;
max-width: 100% !important;
line-height: 36px !important;
height: 36px !important;
padding: 4px 8px !important;
.cm-content {
border-radius: 2px;
border: 1px solid var(--Slate-400, #1d212d);
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
padding: 0px !important;
background-color: #121317 !important;
display: flex !important;
align-items: center !important;
gap: 8px !important;
&:focus-within {
border-color: var(--bg-ink-200);
}
}
.cm-tooltip-autocomplete {
background: var(--bg-ink-300) !important;
border-radius: 2px !important;
font-size: 12px !important;
font-weight: 500 !important;
margin-top: -2px !important;
min-width: 400px !important;
position: absolute !important;
top: 38px !important;
left: 0px !important;
border-radius: 4px;
border: 1px solid var(--bg-slate-200, #1d212d);
border-top: none !important;
border-top-left-radius: 0px !important;
border-top-right-radius: 0px !important;
background: linear-gradient(
139deg,
rgba(18, 19, 23, 0.8) 0%,
rgba(18, 19, 23, 0.9) 98.68%
) !important;
backdrop-filter: blur(20px);
box-sizing: border-box;
overflow: hidden;
font-family: 'Space Mono', monospace !important;
.cm-completionIcon {
display: none !important;
ul {
width: 100% !important;
max-width: 100% !important;
font-family: 'Space Mono', monospace !important;
min-height: 200px !important;
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgb(136, 136, 136);
border-radius: 0.625rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
li {
width: 100% !important;
max-width: 100% !important;
line-height: 36px !important;
height: 36px !important;
padding: 4px 8px !important;
display: flex !important;
align-items: center !important;
gap: 8px !important;
box-sizing: border-box;
overflow: hidden;
font-family: 'Space Mono', monospace !important;
.cm-completionIcon {
display: none !important;
}
&[aria-selected='true'] {
// background-color: rgba(78, 116, 248, 0.7) !important;
background: rgba(171, 189, 255, 0.04) !important;
}
}
}
}
.cm-gutters {
display: none !important;
}
.cm-line {
line-height: 36px !important;
font-family: 'Space Mono', monospace !important;
background-color: #121317 !important;
::-moz-selection {
background: var(--bg-ink-100) !important;
opacity: 0.5 !important;
}
&[aria-selected='true'] {
// background-color: rgba(78, 116, 248, 0.7) !important;
background: rgba(171, 189, 255, 0.04) !important;
::selection {
background: var(--bg-ink-100) !important;
opacity: 0.5 !important;
}
.cm-function {
color: var(--bg-robin-500) !important;
}
.chip-decorator {
background: rgba(36, 40, 52, 1) !important;
color: var(--bg-vanilla-100) !important;
border-radius: 4px;
padding: 2px 4px;
margin-right: 4px;
}
}
.cm-selectionBackground {
background: var(--bg-ink-100) !important;
opacity: 0.5 !important;
}
}
}
.cm-gutters {
display: none !important;
}
.close-btn {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
height: 38px;
width: 38px;
.cm-line {
line-height: 36px !important;
font-family: 'Space Mono', monospace !important;
background-color: #121317 !important;
::-moz-selection {
background: var(--bg-ink-100) !important;
opacity: 0.5 !important;
}
::selection {
background: var(--bg-ink-100) !important;
opacity: 0.5 !important;
}
.cm-function {
color: var(--bg-robin-500) !important;
}
.chip-decorator {
background: rgba(36, 40, 52, 1) !important;
color: var(--bg-vanilla-100) !important;
border-radius: 4px;
padding: 2px 4px;
margin-right: 4px;
}
}
.cm-selectionBackground {
background: var(--bg-ink-100) !important;
opacity: 0.5 !important;
border-left: transparent;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
}
}
.close-btn {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
height: 38px;
width: 38px;
border-left: transparent;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
}

View File

@ -35,24 +35,26 @@ function QueryAggregationOptions({
return (
<div className="query-aggregation-container">
<QueryAggregationSelect />
<div className="aggregation-container">
<QueryAggregationSelect />
{showAggregationInterval && (
<div className="query-aggregation-interval">
<div className="query-aggregation-interval-label">every</div>
<div className="query-aggregation-interval-input-container">
<InputWithLabel
initialValue="60"
label="Seconds"
placeholder="60"
type="number"
onChange={handleAggregationIntervalChange}
labelAfter
onClose={(): void => {}}
/>
{showAggregationInterval && (
<div className="query-aggregation-interval">
<div className="query-aggregation-interval-label">every</div>
<div className="query-aggregation-interval-input-container">
<InputWithLabel
initialValue="60"
label="Seconds"
placeholder="60"
type="number"
onChange={handleAggregationIntervalChange}
labelAfter
onClose={(): void => {}}
/>
</div>
</div>
</div>
)}
)}
</div>
</div>
);
}

View File

@ -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<EditorView | null>(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);
}}
/>
</div>
);

View File

@ -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 (
<Container>
<QueryBuilderV2
isListViewPanel={panelTypes === PANEL_TYPES.LIST}
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
queryComponents={queryComponents}
panelType={panelTypes}
filterConfigs={filterConfigs}
showOnlyWhereClause={
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
}
version="v3" // setting this to v3 as we this is rendered in logs explorer
/>
</Container>
<QueryBuilderV2
isListViewPanel={panelTypes === PANEL_TYPES.LIST}
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
queryComponents={queryComponents}
panelType={panelTypes}
filterConfigs={filterConfigs}
showOnlyWhereClause={
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
}
version="v3" // setting this to v3 as we this is rendered in logs explorer
/>
);
}