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({