diff --git a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx index 4691b1dd2835d8..1169b5a0b47200 100644 --- a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx +++ b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx @@ -206,6 +206,12 @@ type UpdateLogicOperatorAction = { value: string; }; +type WrapTokensWithParenthesesAction = { + tokens: ParseResultToken[]; + type: 'WRAP_TOKENS_WITH_PARENTHESES'; + focusOverride?: FocusOverride; +}; + type ResetClearAskSeerFeedbackAction = {type: 'RESET_CLEAR_ASK_SEER_FEEDBACK'}; type UpdateFreeTextActions = @@ -242,7 +248,8 @@ export type QueryBuilderActions = | UpdateAggregateArgsAction | MultiSelectFilterValueAction | ResetClearAskSeerFeedbackAction - | UpdateLogicOperatorAction; + | UpdateLogicOperatorAction + | WrapTokensWithParenthesesAction; function removeQueryTokensFromQuery( query: string, @@ -437,6 +444,68 @@ function removeExcessWhitespaceFromParts(...parts: string[]): string { .trim(); } +function wrapTokensWithParentheses( + state: QueryBuilderState, + action: WrapTokensWithParenthesesAction, + parseQuery: (query: string) => ParseResult | null +): QueryBuilderState { + if (action.tokens.length === 0) { + return state; + } + + const firstToken = action.tokens[0]!; + const lastToken = action.tokens[action.tokens.length - 1]!; + + const before = state.query.substring(0, firstToken.location.start.offset); + const middle = state.query.substring( + firstToken.location.start.offset, + lastToken.location.end.offset + ); + const after = state.query.substring(lastToken.location.end.offset); + + const newQuery = removeExcessWhitespaceFromParts(before, '(', middle, ')', after); + + // Calculate cursor position: position right after the closing ')' we just added. + let cursorPosition: number; + const trimmedBefore = before.trim(); + const trimmedMiddle = middle.trim(); + if (trimmedMiddle.length === 0) { + // Edge case: empty middle, position at end + cursorPosition = newQuery.length; + } else if (trimmedBefore.length > 0) { + // Format: "trimmedBefore ( trimmedMiddle ) ..." + // Position: trimmedBefore + " " + "(" + " " + trimmedMiddle + " " + ")" = len + 4 + len + 1 + cursorPosition = trimmedBefore.length + trimmedMiddle.length + 5; + } else { + // Format: "( trimmedMiddle ) ..." + // Position: "(" + " " + trimmedMiddle + " " + ")" = 2 + len + 2 + cursorPosition = trimmedMiddle.length + 4; + } + const newParsedQuery = parseQuery(newQuery); + + const focusedToken = newParsedQuery?.find( + (token: any) => + token.type === Token.FREE_TEXT && token.location.start.offset >= cursorPosition + ); + + let focusOverride: FocusOverride | null = null; + if (focusedToken) { + focusOverride = {itemKey: makeTokenKey(focusedToken, newParsedQuery)}; + } else if (newParsedQuery?.[newParsedQuery.length - 1]) { + focusOverride = { + // We know that the last token exists because of the check above, but TS isn't happy + itemKey: makeTokenKey(newParsedQuery[newParsedQuery.length - 1]!, newParsedQuery), + }; + } + + return { + ...state, + query: newQuery, + committedQuery: newQuery, + focusOverride, + }; +} + // Ensures that the replaced token is separated from the rest of the query // and cleans up any extra whitespace function replaceTokensWithPadding( @@ -988,6 +1057,8 @@ export function useQueryBuilderState({ return updateAggregateArgs(state, action, {getFieldDefinition}); case 'TOGGLE_FILTER_VALUE': return multiSelectTokenValue(state, action); + case 'WRAP_TOKENS_WITH_PARENTHESES': + return wrapTokensWithParentheses(state, action, parseQuery); case 'RESET_CLEAR_ASK_SEER_FEEDBACK': return {...state, clearAskSeerFeedback: false}; default: diff --git a/static/app/components/searchQueryBuilder/index.spec.tsx b/static/app/components/searchQueryBuilder/index.spec.tsx index 8b686f2530239d..3d547c41585ed2 100644 --- a/static/app/components/searchQueryBuilder/index.spec.tsx +++ b/static/app/components/searchQueryBuilder/index.spec.tsx @@ -1803,6 +1803,179 @@ describe('SearchQueryBuilder', () => { expect(mockOnChange).toHaveBeenCalledWith('', expect.anything()); }); + it('wraps selected tokens in parentheses with ( key', async () => { + const mockOnChange = jest.fn(); + render( + + ); + + await userEvent.click(getLastInput()); + await userEvent.keyboard('{Control>}a{/Control}'); + await userEvent.keyboard('('); + + expect(mockOnChange).toHaveBeenCalledWith( + '( is:unresolved browser.name:chrome )', + expect.anything() + ); + }); + + it('wraps selected tokens in parentheses with ) key', async () => { + const mockOnChange = jest.fn(); + render( + + ); + + await userEvent.click(getLastInput()); + await userEvent.keyboard('{Control>}a{/Control}'); + await userEvent.keyboard(')'); + + expect(mockOnChange).toHaveBeenCalledWith( + '( is:unresolved browser.name:chrome )', + expect.anything() + ); + }); + + it('wraps single selected token in parentheses', async () => { + const mockOnChange = jest.fn(); + render( + + ); + + await userEvent.click(getLastInput()); + await userEvent.keyboard('{Shift>}{ArrowLeft}{/Shift}'); + await waitFor(() => { + expect(screen.getByRole('row', {name: 'is:unresolved'})).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + await userEvent.keyboard('('); + + expect(mockOnChange).toHaveBeenCalledWith('( is:unresolved )', expect.anything()); + }); + + it('does not wrap when nothing is selected', async () => { + render(); + + await userEvent.click(getLastInput()); + await userEvent.keyboard('('); + + expect(await screen.findByRole('row', {name: '('})).toBeInTheDocument(); + }); + + it('wraps selected tokens correctly when existing parentheses are present', async () => { + const mockOnChange = jest.fn(); + render( + + ); + + await userEvent.click(getLastInput()); + // Select only the browser.name token (shift+left) + await userEvent.keyboard('{Shift>}{ArrowLeft}{/Shift}'); + await waitFor(() => { + expect(screen.getByRole('row', {name: 'browser.name:chrome'})).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + await userEvent.keyboard('('); + + // Should wrap only the selected token, preserving existing parens + expect(mockOnChange).toHaveBeenCalledWith( + '( is:unresolved ) ( browser.name:chrome )', + expect.anything() + ); + }); + + it('wraps selected tokens correctly when duplicate content appears earlier', async () => { + const mockOnChange = jest.fn(); + render( + + ); + + await userEvent.click(getLastInput()); + // Select only the last browser.name token (shift+left) + await userEvent.keyboard('{Shift>}{ArrowLeft}{/Shift}'); + await waitFor(() => { + // Both have the same name, so just check something is selected + const rows = screen.getAllByRole('row', {name: 'browser.name:firefox'}); + expect(rows[1]).toHaveAttribute('aria-selected', 'true'); + }); + await userEvent.keyboard('('); + + // Should wrap the second occurrence correctly + expect(mockOnChange).toHaveBeenCalledWith( + 'browser.name:firefox ( browser.name:firefox )', + expect.anything() + ); + }); + + it('places focus after the closing paren when wrapping', async () => { + render( + + ); + + await userEvent.click(getLastInput()); + await userEvent.keyboard('{Control>}a{/Control}'); + await userEvent.keyboard('('); + + // After wrapping, focus should be at the end (last input) + await waitFor(() => { + expect(getLastInput()).toHaveFocus(); + }); + }); + + it('can undo wrapping with ctrl-z', async () => { + const mockOnChange = jest.fn(); + render( + + ); + + await userEvent.click(getLastInput()); + await userEvent.keyboard('{Control>}a{/Control}'); + await userEvent.keyboard('('); + + expect(mockOnChange).toHaveBeenCalledWith( + '( is:unresolved browser.name:chrome )', + expect.anything() + ); + + await userEvent.keyboard('{Control>}z{/Control}'); + + expect(await screen.findByRole('row', {name: 'is:unresolved'})).toBeInTheDocument(); + expect( + await screen.findByRole('row', {name: 'browser.name:chrome'}) + ).toBeInTheDocument(); + expect(screen.queryByText('(')).not.toBeInTheDocument(); + }); + it('can undo last action with ctrl-z', async () => { render( diff --git a/static/app/components/searchQueryBuilder/selectionKeyHandler.tsx b/static/app/components/searchQueryBuilder/selectionKeyHandler.tsx index c2a1570c1d73df..a9e80fc0e531e9 100644 --- a/static/app/components/searchQueryBuilder/selectionKeyHandler.tsx +++ b/static/app/components/searchQueryBuilder/selectionKeyHandler.tsx @@ -142,6 +142,18 @@ export function SelectionKeyHandler({ return; } + // Wrap selected tokens in parentheses when ( or ) is pressed + if ((e.key === '(' || e.key === ')') && selectedTokens.length > 0) { + e.preventDefault(); + e.stopPropagation(); + dispatch({ + type: 'WRAP_TOKENS_WITH_PARENTHESES', + tokens: selectedTokens, + }); + state.selectionManager.clearSelection(); + return; + } + // If the key pressed will generate a symbol, replace the selection with it if (/^.$/u.test(e.key)) { dispatch({