From 22fe5ce58fa935432e85a9a10d6a786e3658d1dc Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 24 Oct 2025 15:23:51 -0300 Subject: [PATCH 1/7] :necktie: Rework replace logic to skip merging on replace key --- .../hooks/useQueryBuilderState.tsx | 101 +++++++----------- 1 file changed, 37 insertions(+), 64 deletions(-) diff --git a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx index 60f07e5cd9760c..be73579d0cd522 100644 --- a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx +++ b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx @@ -658,21 +658,6 @@ function updateFilterKey( }; } -/** - * Check to see if the provided token details match the primary search key and operator. - * If so, we want to replace this token, with the merged filter value. - */ -function isTokenToBeReplaced( - token: TokenResult, - primarySearchKey: string -): token is TokenResult { - return ( - token.type === Token.FILTER && - getKeyName(token.key) === primarySearchKey && - token.operator === TermOperator.CONTAINS - ); -} - /** * This function is used to replace free text tokens with the specified * `replaceRawSearchKeys` prop from `SearchQueryBuilder`. This function also handles @@ -704,73 +689,61 @@ export function replaceFreeTextTokens( return undefined; } - const actionTokens = parseQueryBuilderValue(action.text, getFieldDefinition) ?? []; - if (actionTokens.every(token => token.type !== Token.FREE_TEXT)) { - return undefined; - } - - const tokens = parseQueryBuilderValue(currentQuery, getFieldDefinition) ?? []; - // TS doesn't know that replaceRawSearchKeys is always defined and non-empty const primarySearchKey = replaceRawSearchKeys[0] ?? ''; - let tokenToBeReplaced: TokenResult | undefined; - const freeTextToken = actionTokens.find( - token => token.type === Token.FREE_TEXT && /\w/.test(token.value) - ); - - for (const token of tokens) { - if (isTokenToBeReplaced(token, primarySearchKey)) { - tokenToBeReplaced = token; - break; + const actionTokens = + parseQueryBuilderValue(action.text.trim(), getFieldDefinition) ?? []; + + let foundFreeTextTokenWithContent = false; + const actionQuery = actionTokens.map(token => { + if ( + token.type !== Token.FREE_TEXT || + (token.type === Token.FREE_TEXT && token.value.trim().length === 0) + ) { + return token.text; } - } - const valueText = freeTextToken?.text.trim(); - if (!valueText) { - return undefined; - } - const values = escapeTagValue(valueText); - - const filteredTokens = new Set(); - actionTokens.forEach(token => { - const isNotFreeText = token.type !== Token.FREE_TEXT; - - if (isNotFreeText && !isTokenToBeReplaced(token, primarySearchKey)) { - filteredTokens.add(token.text); + if (!foundFreeTextTokenWithContent) { + foundFreeTextTokenWithContent = true; } - }); - tokens.forEach(token => { - const isNotFreeText = token.type !== Token.FREE_TEXT; - if (isNotFreeText && !isTokenToBeReplaced(token, primarySearchKey)) { - filteredTokens.add(token.text); + let value = token.text; + if (value.includes(' ')) { + value = escapeTagValue(value); } + + return `${primarySearchKey}:${WildcardOperators.CONTAINS}${value}`; }); - // case when there is a replace key and value present - if (tokenToBeReplaced) { - const previousValue = - tokenToBeReplaced.value.type === Token.VALUE_TEXT_LIST - ? tokenToBeReplaced.value.text.slice(1, -1) - : tokenToBeReplaced.value.text; + const freeTextTokens: string[] = []; + const filteredTokensArray: string[] = []; + const currentQueryTokens = + parseQueryBuilderValue(currentQuery, getFieldDefinition) ?? []; - filteredTokens.add( - `${primarySearchKey}:${WildcardOperators.CONTAINS}[${previousValue},${values}]` - ); - } else { - filteredTokens.add(`${primarySearchKey}:${WildcardOperators.CONTAINS}${values}`); + for (const token of currentQueryTokens) { + if (token.type !== Token.FREE_TEXT) { + filteredTokensArray.push(stringifyToken(token)); + } else if (token.text.trim().length > 0) { + freeTextTokens.push(token.text); + } + } + + // there are no free text tokens to be replaced so we return undefined - saves a + // parsing step + if (!foundFreeTextTokenWithContent && freeTextTokens.length === 0) { + return undefined; } - const newQuery = Array.from(filteredTokens).join(' '); + const finalQuery = + `${filteredTokensArray.join(' ').trim()} ${actionQuery.join(' ').trim()}`.trim(); - const newParsedQuery = parseQueryBuilderValue(newQuery, getFieldDefinition) ?? []; + const newParsedQuery = parseQueryBuilderValue(finalQuery, getFieldDefinition) ?? []; const focusedToken = newParsedQuery?.findLast(token => token.type === Token.FREE_TEXT); - const focusOverride = focusedToken ? {itemKey: makeTokenKey(focusedToken, newParsedQuery)} : null; - return {newQuery, focusOverride}; + return {newQuery: finalQuery, focusOverride}; } function updateFreeTextAndReplaceText( From c5acec0b111de052e65ee21e3aa8439ca56ca2d1 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 24 Oct 2025 15:24:10 -0300 Subject: [PATCH 2/7] :white_check_mark: Update tests --- .../hooks/useQueryBuilderState.spec.tsx | 8 ++++---- static/app/components/searchQueryBuilder/index.spec.tsx | 7 ++++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.spec.tsx b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.spec.tsx index 7e7c350402d9a9..0dbb9d5601045c 100644 --- a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.spec.tsx +++ b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.spec.tsx @@ -217,8 +217,8 @@ describe('replaceFreeTextTokens', () => { currentQuery: `span.description:${WildcardOperators.CONTAINS}test`, }, expected: { - query: `span.description:${WildcardOperators.CONTAINS}[test,test2]`, - focusOverride: {itemKey: 'freeText:1'}, + query: `span.description:${WildcardOperators.CONTAINS}test span.description:${WildcardOperators.CONTAINS}test2`, + focusOverride: {itemKey: 'freeText:2'}, }, }, { @@ -235,8 +235,8 @@ describe('replaceFreeTextTokens', () => { currentQuery: `span.description:${WildcardOperators.CONTAINS}test`, }, expected: { - query: `span.description:${WildcardOperators.CONTAINS}[test,"other value"]`, - focusOverride: {itemKey: 'freeText:1'}, + query: `span.description:${WildcardOperators.CONTAINS}test span.description:${WildcardOperators.CONTAINS}"other value"`, + focusOverride: {itemKey: 'freeText:2'}, }, }, { diff --git a/static/app/components/searchQueryBuilder/index.spec.tsx b/static/app/components/searchQueryBuilder/index.spec.tsx index df787893ac8051..72f9024216dd99 100644 --- a/static/app/components/searchQueryBuilder/index.spec.tsx +++ b/static/app/components/searchQueryBuilder/index.spec.tsx @@ -4344,7 +4344,12 @@ describe('SearchQueryBuilder', () => { // Should have tokenized the pasted text expect( screen.getByRole('row', { - name: `span.description:${WildcardOperators.CONTAINS}[test,randomValue]`, + name: `span.description:${WildcardOperators.CONTAINS}test`, + }) + ).toBeInTheDocument(); + expect( + screen.getByRole('row', { + name: `span.description:${WildcardOperators.CONTAINS}randomValue`, }) ).toBeInTheDocument(); // Focus should be at the end of the pasted text From 7a7a876a1c546ca8f2ebc524e22034a86f064956 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 24 Oct 2025 16:01:30 -0300 Subject: [PATCH 3/7] :recycle: Refactor replacement to replace the already updated query --- .../hooks/useQueryBuilderState.tsx | 89 ++++++++----------- 1 file changed, 37 insertions(+), 52 deletions(-) diff --git a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx index be73579d0cd522..b24480ef1c219a 100644 --- a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx +++ b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx @@ -671,72 +671,59 @@ function updateFilterKey( * description:[*test*,"*some text*"]` */ export function replaceFreeTextTokens( - action: - | ReplaceTokensWithTextOnPasteAction - | UpdateFreeTextActionOnCommit - | UpdateFreeTextActionOnBlur - | UpdateFreeTextActionOnExit, + currentQuery: string, getFieldDefinition: FieldDefinitionGetter, - replaceRawSearchKeys: string[], - currentQuery: string + replaceRawSearchKeys: string[] ) { if ( - !action.text || - action.text === '' || + currentQuery.trim().length === 0 || replaceRawSearchKeys.length === 0 || (replaceRawSearchKeys.length !== 0 && replaceRawSearchKeys[0] === '') ) { return undefined; } - // TS doesn't know that replaceRawSearchKeys is always defined and non-empty + const currentQueryTokens = + parseQueryBuilderValue(currentQuery, getFieldDefinition) ?? []; + + const foundFreeTextToken = currentQueryTokens.some( + token => token.type === Token.FREE_TEXT && token.text.trim().length > 0 + ); + + if (!foundFreeTextToken) { + return undefined; + } + const primarySearchKey = replaceRawSearchKeys[0] ?? ''; - const actionTokens = - parseQueryBuilderValue(action.text.trim(), getFieldDefinition) ?? []; - - let foundFreeTextTokenWithContent = false; - const actionQuery = actionTokens.map(token => { - if ( - token.type !== Token.FREE_TEXT || - (token.type === Token.FREE_TEXT && token.value.trim().length === 0) - ) { - return token.text; + const replacedQuery: string[] = []; + for (const token of currentQueryTokens) { + if (token.type === Token.L_PAREN) { + replacedQuery.push('('); + continue; } - if (!foundFreeTextTokenWithContent) { - foundFreeTextTokenWithContent = true; + if (token.type === Token.R_PAREN) { + replacedQuery.push(')'); + continue; } - let value = token.text; - if (value.includes(' ')) { - value = escapeTagValue(value); + if (token.type !== Token.FREE_TEXT) { + const stringifiedToken = stringifyToken(token); + if (stringifiedToken.length > 0) { + replacedQuery.push(stringifiedToken); + } + continue; } - return `${primarySearchKey}:${WildcardOperators.CONTAINS}${value}`; - }); - - const freeTextTokens: string[] = []; - const filteredTokensArray: string[] = []; - const currentQueryTokens = - parseQueryBuilderValue(currentQuery, getFieldDefinition) ?? []; - - for (const token of currentQueryTokens) { - if (token.type !== Token.FREE_TEXT) { - filteredTokensArray.push(stringifyToken(token)); - } else if (token.text.trim().length > 0) { - freeTextTokens.push(token.text); + if (token.text.trim().length === 0) { + continue; } - } - // there are no free text tokens to be replaced so we return undefined - saves a - // parsing step - if (!foundFreeTextTokenWithContent && freeTextTokens.length === 0) { - return undefined; + const value = escapeTagValue(token.text.trim()); + replacedQuery.push(`${primarySearchKey}:${WildcardOperators.CONTAINS}${value}`); } - const finalQuery = - `${filteredTokensArray.join(' ').trim()} ${actionQuery.join(' ').trim()}`.trim(); - + const finalQuery = replacedQuery.join(' ').trim(); const newParsedQuery = parseQueryBuilderValue(finalQuery, getFieldDefinition) ?? []; const focusedToken = newParsedQuery?.findLast(token => token.type === Token.FREE_TEXT); const focusOverride = focusedToken @@ -767,10 +754,9 @@ function updateFreeTextAndReplaceText( } const replacedState = replaceFreeTextTokens( - action, + newState.query, getFieldDefinition, - replaceRawSearchKeys ?? [], - newState.query + replaceRawSearchKeys ?? [] ); const query = replacedState?.newQuery ? replacedState.newQuery : newState.query; @@ -930,10 +916,9 @@ export function useQueryBuilderState({ } const replacedState = replaceFreeTextTokens( - action, + newState.query, getFieldDefinition, - replaceRawSearchKeys ?? [], - state.query + replaceRawSearchKeys ?? [] ); const query = replacedState?.newQuery ? replacedState.newQuery : newState.query; From f966b76a8edce1073e51faf12011175a0239df53 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 24 Oct 2025 16:01:44 -0300 Subject: [PATCH 4/7] :white_check_mark: Update tests --- .../hooks/useQueryBuilderState.spec.tsx | 105 ++---------------- 1 file changed, 11 insertions(+), 94 deletions(-) diff --git a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.spec.tsx b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.spec.tsx index 0dbb9d5601045c..b597f412601640 100644 --- a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.spec.tsx +++ b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.spec.tsx @@ -1,10 +1,7 @@ import type {FocusOverride} from 'sentry/components/searchQueryBuilder/types'; import {WildcardOperators} from 'sentry/components/searchSyntax/parser'; -import { - replaceFreeTextTokens, - type ReplaceTokensWithTextOnPasteAction, -} from './useQueryBuilderState'; +import {replaceFreeTextTokens} from './useQueryBuilderState'; describe('replaceFreeTextTokens', () => { describe('when there are free text tokens', () => { @@ -15,7 +12,6 @@ describe('replaceFreeTextTokens', () => { query: string | undefined; }; input: { - action: ReplaceTokensWithTextOnPasteAction; currentQuery: string; getFieldDefinition: () => null; rawSearchReplacement: string[]; @@ -26,12 +22,6 @@ describe('replaceFreeTextTokens', () => { { description: 'when there are no tokens', input: { - action: { - type: 'REPLACE_TOKENS_WITH_TEXT_ON_PASTE', - text: '', - tokens: [], - focusOverride: undefined, - }, getFieldDefinition: () => null, rawSearchReplacement: ['span.description'], currentQuery: '', @@ -44,12 +34,6 @@ describe('replaceFreeTextTokens', () => { { description: 'when the replace raw search keys is empty', input: { - action: { - type: 'REPLACE_TOKENS_WITH_TEXT_ON_PASTE', - text: '', - tokens: [], - focusOverride: undefined, - }, getFieldDefinition: () => null, rawSearchReplacement: [], currentQuery: '', @@ -62,12 +46,6 @@ describe('replaceFreeTextTokens', () => { { description: 'when the replace raw search keys is an empty string', input: { - action: { - type: 'REPLACE_TOKENS_WITH_TEXT_ON_PASTE', - text: '', - tokens: [], - focusOverride: undefined, - }, getFieldDefinition: () => null, rawSearchReplacement: [''], currentQuery: '', @@ -80,12 +58,6 @@ describe('replaceFreeTextTokens', () => { { description: 'when there is no raw search replacement', input: { - action: { - type: 'REPLACE_TOKENS_WITH_TEXT_ON_PASTE', - text: '', - tokens: [], - focusOverride: undefined, - }, getFieldDefinition: () => null, rawSearchReplacement: [], currentQuery: `browser.name:${WildcardOperators.CONTAINS}"firefox"`, @@ -98,12 +70,6 @@ describe('replaceFreeTextTokens', () => { { description: 'when there are no free text tokens', input: { - action: { - type: 'REPLACE_TOKENS_WITH_TEXT_ON_PASTE', - text: '', - tokens: [], - focusOverride: undefined, - }, getFieldDefinition: () => null, rawSearchReplacement: ['span.description'], currentQuery: `browser.name:${WildcardOperators.CONTAINS}"firefox"`, @@ -116,15 +82,9 @@ describe('replaceFreeTextTokens', () => { { description: 'when there only valid action tokens', input: { - action: { - type: 'REPLACE_TOKENS_WITH_TEXT_ON_PASTE', - text: 'span.op:eq', - tokens: [], - focusOverride: undefined, - }, getFieldDefinition: () => null, rawSearchReplacement: ['span.description'], - currentQuery: '', + currentQuery: 'span.op:eq', }, expected: { query: undefined, @@ -134,15 +94,9 @@ describe('replaceFreeTextTokens', () => { { description: 'when there only space free text tokens in the action', input: { - action: { - type: 'REPLACE_TOKENS_WITH_TEXT_ON_PASTE', - text: 'span.op:eq ', - tokens: [], - focusOverride: undefined, - }, getFieldDefinition: () => null, rawSearchReplacement: ['span.description'], - currentQuery: '', + currentQuery: 'span.op:eq ', }, expected: { query: undefined, @@ -152,15 +106,9 @@ describe('replaceFreeTextTokens', () => { { description: 'when there is one free text token', input: { - action: { - type: 'REPLACE_TOKENS_WITH_TEXT_ON_PASTE', - text: 'test', - tokens: [], - focusOverride: undefined, - }, getFieldDefinition: () => null, rawSearchReplacement: ['span.description'], - currentQuery: '', + currentQuery: 'test', }, expected: { query: `span.description:${WildcardOperators.CONTAINS}test`, @@ -170,15 +118,9 @@ describe('replaceFreeTextTokens', () => { { description: 'when there is one free text token that has a space', input: { - action: { - type: 'REPLACE_TOKENS_WITH_TEXT_ON_PASTE', - text: 'test test', - tokens: [], - focusOverride: undefined, - }, getFieldDefinition: () => null, rawSearchReplacement: ['span.description'], - currentQuery: '', + currentQuery: 'test test', }, expected: { query: `span.description:${WildcardOperators.CONTAINS}"test test"`, @@ -188,15 +130,9 @@ describe('replaceFreeTextTokens', () => { { description: 'when there is already a token present', input: { - action: { - type: 'REPLACE_TOKENS_WITH_TEXT_ON_PASTE', - text: 'test', - tokens: [], - focusOverride: undefined, - }, getFieldDefinition: () => null, rawSearchReplacement: ['span.description'], - currentQuery: 'span.op:eq', + currentQuery: 'span.op:eq test', }, expected: { query: `span.op:eq span.description:${WildcardOperators.CONTAINS}test`, @@ -206,15 +142,9 @@ describe('replaceFreeTextTokens', () => { { description: 'when there is already a replace token present', input: { - action: { - type: 'REPLACE_TOKENS_WITH_TEXT_ON_PASTE', - text: 'test2', - tokens: [], - focusOverride: undefined, - }, getFieldDefinition: () => null, rawSearchReplacement: ['span.description'], - currentQuery: `span.description:${WildcardOperators.CONTAINS}test`, + currentQuery: `span.description:${WildcardOperators.CONTAINS}test test2`, }, expected: { query: `span.description:${WildcardOperators.CONTAINS}test span.description:${WildcardOperators.CONTAINS}test2`, @@ -224,15 +154,9 @@ describe('replaceFreeTextTokens', () => { { description: 'when there is already a replace token present with a space', input: { - action: { - type: 'REPLACE_TOKENS_WITH_TEXT_ON_PASTE', - text: 'other value', - tokens: [], - focusOverride: undefined, - }, getFieldDefinition: () => null, rawSearchReplacement: ['span.description'], - currentQuery: `span.description:${WildcardOperators.CONTAINS}test`, + currentQuery: `span.description:${WildcardOperators.CONTAINS}test other value`, }, expected: { query: `span.description:${WildcardOperators.CONTAINS}test span.description:${WildcardOperators.CONTAINS}"other value"`, @@ -243,15 +167,9 @@ describe('replaceFreeTextTokens', () => { description: 'when there is already a replace token present with a different operator', input: { - action: { - type: 'REPLACE_TOKENS_WITH_TEXT_ON_PASTE', - text: 'other value', - tokens: [], - focusOverride: undefined, - }, getFieldDefinition: () => null, rawSearchReplacement: ['span.description'], - currentQuery: `span.description:test`, + currentQuery: `span.description:test other value`, }, expected: { query: `span.description:test span.description:${WildcardOperators.CONTAINS}"other value"`, @@ -262,10 +180,9 @@ describe('replaceFreeTextTokens', () => { it.each(testCases)('$description', ({input, expected}) => { const result = replaceFreeTextTokens( - input.action, + input.currentQuery, input.getFieldDefinition, - input.rawSearchReplacement, - input.currentQuery + input.rawSearchReplacement ); expect(result?.newQuery).toBe(expected.query); From 5c49a6801e21ac8da981327e783dbb448afb0aa2 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 24 Oct 2025 16:08:53 -0300 Subject: [PATCH 5/7] :rotating_light: Resolve knip issue --- .../searchQueryBuilder/hooks/useQueryBuilderState.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx index b24480ef1c219a..9967a43e618f58 100644 --- a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx +++ b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx @@ -135,7 +135,7 @@ type UpdateFreeTextActionOnColon = { focusOverride?: FocusOverride; }; -export type ReplaceTokensWithTextOnPasteAction = { +type ReplaceTokensWithTextOnPasteAction = { text: string; tokens: ParseResultToken[]; type: 'REPLACE_TOKENS_WITH_TEXT_ON_PASTE'; From 5b1d6c9f0e2158456fcd858e4afd9cb8f82d2531 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 27 Oct 2025 07:42:24 -0300 Subject: [PATCH 6/7] :sparkles: Replace free text on update query --- .../hooks/useQueryBuilderState.tsx | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx index 9967a43e618f58..cead360f31ef37 100644 --- a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx +++ b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx @@ -832,12 +832,33 @@ export function useQueryBuilderState({ return {...state, committedQuery: state.query}; case 'UPDATE_QUERY': { const shouldCommitQuery = action.shouldCommitQuery ?? true; - return { - ...state, - query: action.query, - committedQuery: shouldCommitQuery ? action.query : state.committedQuery, - focusOverride: action.focusOverride ?? null, - }; + + if ( + !hasWildcardOperators || + !replaceRawSearchKeys || + replaceRawSearchKeys.length === 0 + ) { + return { + ...state, + query: action.query, + committedQuery: shouldCommitQuery ? action.query : state.committedQuery, + focusOverride: action.focusOverride ?? null, + }; + } + + const replacedState = replaceFreeTextTokens( + action.query, + getFieldDefinition, + replaceRawSearchKeys + ); + + const query = replacedState?.newQuery ? replacedState.newQuery : action.query; + const committedQuery = shouldCommitQuery ? query : state.committedQuery; + const focusOverride = replacedState?.focusOverride + ? replacedState.focusOverride + : (action.focusOverride ?? null); + + return {...state, query, committedQuery, focusOverride}; } case 'RESET_FOCUS_OVERRIDE': return { From 8b7f09e55d9aefed055e5b41d86d987ed8146881 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 27 Oct 2025 07:52:39 -0300 Subject: [PATCH 7/7] :white_check_mark: Add in test for new case --- .../searchQueryBuilder/index.spec.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/static/app/components/searchQueryBuilder/index.spec.tsx b/static/app/components/searchQueryBuilder/index.spec.tsx index 72f9024216dd99..a55ceca237c5e7 100644 --- a/static/app/components/searchQueryBuilder/index.spec.tsx +++ b/static/app/components/searchQueryBuilder/index.spec.tsx @@ -4429,6 +4429,49 @@ describe('SearchQueryBuilder', () => { expect(getLastInput()).toHaveFocus(); }); }); + + describe('selecting from filter key suggestions', () => { + beforeEach(() => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/recent-searches/', + body: [{query: 'a or b'}, {query: 'some recent query'}], + }); + }); + + it('should replace the raw search key with the defined key:value', async () => { + render( + , + {organization: {features: ['search-query-builder-wildcard-operators']}} + ); + + await userEvent.click(getLastInput()); + + const aOrBOption = await screen.findByRole('option', {name: 'a or b'}); + expect(aOrBOption).toBeInTheDocument(); + + await userEvent.hover(aOrBOption); + await userEvent.keyboard('{enter}{enter}'); + + expect( + await screen.findByRole('row', { + name: `span.description:${WildcardOperators.CONTAINS}a`, + }) + ).toBeInTheDocument(); + + expect(await screen.findByRole('row', {name: 'OR'})).toBeInTheDocument(); + + expect( + await screen.findByRole('row', { + name: `span.description:${WildcardOperators.CONTAINS}b`, + }) + ).toBeInTheDocument(); + }); + }); }); });