diff --git a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx index 1169b5a0b47200..470d816476078b 100644 --- a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx +++ b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx @@ -185,6 +185,7 @@ type UpdateTokenValueAction = { token: TokenResult; type: 'UPDATE_TOKEN_VALUE'; value: string; + op?: TermOperator; }; type MultiSelectFilterValueAction = { @@ -280,6 +281,32 @@ function deleteQueryTokens( }; } +function termOperatorToInternal(op: TermOperator): { + internalOp: TermOperator; + negated: boolean; +} { + const negated = + op === TermOperator.NOT_EQUAL || + op === TermOperator.DOES_NOT_CONTAIN || + op === TermOperator.DOES_NOT_START_WITH || + op === TermOperator.DOES_NOT_END_WITH; + + let internalOp: TermOperator; + if (op === TermOperator.DOES_NOT_CONTAIN) { + internalOp = TermOperator.CONTAINS; + } else if (op === TermOperator.DOES_NOT_START_WITH) { + internalOp = TermOperator.STARTS_WITH; + } else if (op === TermOperator.DOES_NOT_END_WITH) { + internalOp = TermOperator.ENDS_WITH; + } else if (op === TermOperator.NOT_EQUAL) { + internalOp = TermOperator.DEFAULT; + } else { + internalOp = op; + } + + return {negated, internalOp}; +} + export function modifyFilterOperatorQuery( query: string, token: TokenResult, @@ -289,23 +316,10 @@ export function modifyFilterOperatorQuery( return modifyFilterOperatorDate(query, token, newOperator); } + const {negated, internalOp} = termOperatorToInternal(newOperator); const newToken: TokenResult = {...token}; - newToken.negated = - newOperator === TermOperator.NOT_EQUAL || - newOperator === TermOperator.DOES_NOT_CONTAIN || - newOperator === TermOperator.DOES_NOT_START_WITH || - newOperator === TermOperator.DOES_NOT_END_WITH; - - if (newOperator === TermOperator.DOES_NOT_CONTAIN) { - newToken.operator = TermOperator.CONTAINS; - } else if (newOperator === TermOperator.DOES_NOT_START_WITH) { - newToken.operator = TermOperator.STARTS_WITH; - } else if (newOperator === TermOperator.DOES_NOT_END_WITH) { - newToken.operator = TermOperator.ENDS_WITH; - } else { - newToken.operator = - newOperator === TermOperator.NOT_EQUAL ? TermOperator.DEFAULT : newOperator; - } + newToken.negated = negated; + newToken.operator = internalOp; return replaceQueryToken(query, token, stringifyToken(newToken)); } @@ -605,7 +619,8 @@ function replaceTokensWithText( export function modifyFilterValue( query: string, token: TokenResult, - newValue: string + newValue: string, + newOp?: TermOperator ): string { if (isDateToken(token)) { return modifyFilterValueDate(query, token, newValue); @@ -614,7 +629,18 @@ export function modifyFilterValue( // stop the user from entering multiple wildcards by themselves newValue = newValue.replace(/\*\*+/g, '*'); - return replaceQueryToken(query, token.value, newValue); + // No operator change — just replace the value (existing behavior) + if (newOp === undefined) { + return replaceQueryToken(query, token.value, newValue); + } + + // Operator change — replace the entire filter token atomically + const {negated, internalOp} = termOperatorToInternal(newOp); + + const prefix = negated ? '!' : ''; + const keyStr = stringifyToken(token.key); + const replacement = `${prefix}${keyStr}:${internalOp}${newValue}`; + return replaceQueryToken(query, token, replacement); } function updateFilterMultipleValues( @@ -1049,7 +1075,7 @@ export function useQueryBuilderState({ case 'UPDATE_TOKEN_VALUE': return { ...state, - query: modifyFilterValue(state.query, action.token, action.value), + query: modifyFilterValue(state.query, action.token, action.value, action.op), }; case 'UPDATE_LOGIC_OPERATOR': return updateLogicOperator(state, action); diff --git a/static/app/components/searchQueryBuilder/index.spec.tsx b/static/app/components/searchQueryBuilder/index.spec.tsx index 8cd8cf77dded29..4e82b8c027c7c7 100644 --- a/static/app/components/searchQueryBuilder/index.spec.tsx +++ b/static/app/components/searchQueryBuilder/index.spec.tsx @@ -1089,21 +1089,43 @@ describe('SearchQueryBuilder', () => { await userEvent.click(await screen.findByRole('option', {name: 'Firefox'})); - // New token should have a value + // New token should have a value, and selecting from dropdown switches operator to "is" expect( screen.getByRole('row', { - name: `browser.name:${WildcardOperators.CONTAINS}Firefox`, + name: 'browser.name:Firefox', }) ).toBeInTheDocument(); // Now we call onChange expect(mockOnChange).toHaveBeenCalledTimes(1); expect(mockOnChange).toHaveBeenCalledWith( - `browser.name:${WildcardOperators.CONTAINS}Firefox`, + 'browser.name:Firefox', expect.anything() ); }); + it('does not switch operator to "is" when filter already has a value', async () => { + render( + , + {organization: {features: ['search-query-builder-input-flow-changes']}} + ); + + await userEvent.click( + screen.getByRole('button', {name: 'Edit value for filter: browser.name'}) + ); + await userEvent.click(await screen.findByRole('option', {name: 'Chrome'})); + + // Operator should remain "contains" since there was already a value + expect( + await screen.findByRole('row', { + name: `browser.name:${WildcardOperators.CONTAINS}[firefox,Chrome]`, + }) + ).toBeInTheDocument(); + }); + it('can add free text by typing', async () => { const mockOnSearch = jest.fn(); render(); @@ -1266,9 +1288,10 @@ describe('SearchQueryBuilder', () => { await userEvent.keyboard('{enter}'); await userEvent.click(screen.getByRole('option', {name: '[Filtered]'})); + // Selecting from dropdown switches operator from contains to "is" expect( await screen.findByRole('row', { - name: `message:${WildcardOperators.CONTAINS}"[Filtered]"`, + name: 'message:"[Filtered]"', }) ).toBeInTheDocument(); }); diff --git a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx index ca22790f1d133d..ab8cdef9324ba4 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx @@ -749,7 +749,7 @@ export function SearchQueryBuilderValueCombobox({ ); const updateFilterValue = useCallback( - (value: string) => { + (value: string, op?: TermOperator) => { if (token.filter === FilterType.HAS) { const suggested = getSuggestedFilterKey(value); if (suggested) { @@ -793,6 +793,7 @@ export function SearchQueryBuilderValueCombobox({ type: 'UPDATE_TOKEN_VALUE', token, value: newValue, + op, }); if (newValue && newValue !== '""' && !ctrlKeyPressed) { @@ -809,6 +810,7 @@ export function SearchQueryBuilderValueCombobox({ getFilterValueType(token, fieldDefinition), replaceCommaSeparatedValue(inputValue, selectionIndex, escapeTagValue(value)) ), + op, }); if (!ctrlKeyPressed) { @@ -819,6 +821,7 @@ export function SearchQueryBuilderValueCombobox({ type: 'UPDATE_TOKEN_VALUE', token, value: cleanedValue, + op, }); onCommit(); } @@ -860,7 +863,17 @@ export function SearchQueryBuilderValueCombobox({ return; } - updateFilterValue(value); + // When selecting from dropdown with no existing value, switch from "contains" to "is" + let newOp: TermOperator | undefined; + if ( + token.operator === TermOperator.CONTAINS && + token.value.type === Token.VALUE_TEXT && + !token.value.value + ) { + newOp = token.negated ? TermOperator.NOT_EQUAL : TermOperator.DEFAULT; + } + + updateFilterValue(value, newOp); trackAnalytics('search.value_autocompleted', { ...analyticsData, filter_value: value,