Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -242,7 +248,8 @@ export type QueryBuilderActions =
| UpdateAggregateArgsAction
| MultiSelectFilterValueAction
| ResetClearAskSeerFeedbackAction
| UpdateLogicOperatorAction;
| UpdateLogicOperatorAction
| WrapTokensWithParenthesesAction;

function removeQueryTokensFromQuery(
query: string,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
173 changes: 173 additions & 0 deletions static/app/components/searchQueryBuilder/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<SearchQueryBuilder
{...defaultProps}
initialQuery="is:unresolved browser.name:chrome"
onChange={mockOnChange}
/>
);

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(
<SearchQueryBuilder
{...defaultProps}
initialQuery="is:unresolved browser.name:chrome"
onChange={mockOnChange}
/>
);

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(
<SearchQueryBuilder
{...defaultProps}
initialQuery="is:unresolved"
onChange={mockOnChange}
/>
);

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(<SearchQueryBuilder {...defaultProps} initialQuery="is:unresolved" />);

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(
<SearchQueryBuilder
{...defaultProps}
initialQuery="( is:unresolved ) browser.name:chrome"
onChange={mockOnChange}
/>
);

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(
<SearchQueryBuilder
{...defaultProps}
initialQuery="browser.name:firefox browser.name:firefox"
onChange={mockOnChange}
/>
);

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(
<SearchQueryBuilder
{...defaultProps}
initialQuery="is:unresolved browser.name:chrome"
/>
);

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(
<SearchQueryBuilder
{...defaultProps}
initialQuery="is:unresolved browser.name:chrome"
onChange={mockOnChange}
/>
);

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(
<SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
Expand Down
12 changes: 12 additions & 0 deletions static/app/components/searchQueryBuilder/selectionKeyHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading