Skip to content

Commit eb193e0

Browse files
authored
fix(query-builder): Modify callbacks to include more information and only fire when necessary (#75082)
The onChange was firing far too frequently, because the `onChange` passed in was not memoized. We can prevent this by tracking `previousQuery`. Also added some extra info to the callback, like whether the query is valid and the parsed contents since that should be useful for some implementations.
1 parent 7f458d2 commit eb193e0

File tree

5 files changed

+73
-27
lines changed

5 files changed

+73
-27
lines changed

static/app/components/searchQueryBuilder/hooks/useHandleSearch.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import * as Sentry from '@sentry/react';
33

44
import {saveRecentSearch} from 'sentry/actionCreators/savedSearches';
55
import type {Client} from 'sentry/api';
6+
import type {CallbackSearchState} from 'sentry/components/searchQueryBuilder/types';
67
import {
8+
queryIsValid,
79
recentSearchTypeToLabel,
810
tokenIsInvalid,
911
} from 'sentry/components/searchQueryBuilder/utils';
@@ -18,7 +20,7 @@ type UseHandleSearchProps = {
1820
parsedQuery: ParseResult | null;
1921
recentSearches: SavedSearchType | undefined;
2022
searchSource: string;
21-
onSearch?: (query: string) => void;
23+
onSearch?: (query: string, state: CallbackSearchState) => void;
2224
};
2325

2426
async function saveAsRecentSearch({
@@ -97,7 +99,7 @@ export function useHandleSearch({
9799

98100
return useCallback(
99101
(query: string) => {
100-
onSearch?.(query);
102+
onSearch?.(query, {parsedQuery, queryIsValid: queryIsValid(parsedQuery)});
101103

102104
const searchType = recentSearchTypeToLabel(recentSearches);
103105

static/app/components/searchQueryBuilder/index.spec.tsx

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -132,27 +132,35 @@ describe('SearchQueryBuilder', function () {
132132
render(
133133
<SearchQueryBuilder
134134
{...defaultProps}
135-
initialQuery=""
135+
initialQuery="a"
136136
onChange={mockOnChange}
137137
onBlur={mockOnBlur}
138138
onSearch={mockOnSearch}
139139
/>
140140
);
141141

142142
await userEvent.click(getLastInput());
143-
await userEvent.keyboard('foo{enter}');
143+
await userEvent.keyboard('b{enter}');
144+
145+
const expectedQueryState = expect.objectContaining({
146+
parsedQuery: expect.arrayContaining([expect.any(Object)]),
147+
queryIsValid: true,
148+
});
144149

145150
// Should call onChange and onSearch after enter
146151
await waitFor(() => {
147-
expect(mockOnChange).toHaveBeenCalledWith('foo');
148-
expect(mockOnSearch).toHaveBeenCalledWith('foo');
152+
expect(mockOnChange).toHaveBeenCalledTimes(1);
153+
expect(mockOnChange).toHaveBeenCalledWith('ab', expectedQueryState);
154+
expect(mockOnSearch).toHaveBeenCalledTimes(1);
155+
expect(mockOnSearch).toHaveBeenCalledWith('ab', expectedQueryState);
149156
});
150157

151158
await userEvent.click(document.body);
152159

153160
// Clicking outside activates onBlur
154161
await waitFor(() => {
155-
expect(mockOnBlur).toHaveBeenCalledWith('foo');
162+
expect(mockOnBlur).toHaveBeenCalledTimes(1);
163+
expect(mockOnBlur).toHaveBeenCalledWith('ab', expectedQueryState);
156164
});
157165
});
158166
});
@@ -172,8 +180,8 @@ describe('SearchQueryBuilder', function () {
172180
userEvent.click(screen.getByRole('button', {name: 'Clear search query'}));
173181

174182
await waitFor(() => {
175-
expect(mockOnChange).toHaveBeenCalledWith('');
176-
expect(mockOnSearch).toHaveBeenCalledWith('');
183+
expect(mockOnChange).toHaveBeenCalledWith('', expect.anything());
184+
expect(mockOnSearch).toHaveBeenCalledWith('', expect.anything());
177185
});
178186

179187
expect(
@@ -253,7 +261,10 @@ describe('SearchQueryBuilder', function () {
253261
expect(screen.getByRole('textbox')).toHaveValue('browser.name:firefox assigned:me');
254262

255263
await waitFor(() => {
256-
expect(mockOnChange).toHaveBeenLastCalledWith('browser.name:firefox assigned:me');
264+
expect(mockOnChange).toHaveBeenLastCalledWith(
265+
'browser.name:firefox assigned:me',
266+
expect.anything()
267+
);
257268
});
258269
});
259270
});
@@ -534,7 +545,7 @@ describe('SearchQueryBuilder', function () {
534545
await userEvent.click(getLastInput());
535546
await userEvent.type(screen.getByRole('combobox'), 'some free text{enter}');
536547
await waitFor(() => {
537-
expect(mockOnSearch).toHaveBeenCalledWith('some free text');
548+
expect(mockOnSearch).toHaveBeenCalledWith('some free text', expect.anything());
538549
});
539550
// Should still have text in the input
540551
expect(screen.getByRole('combobox')).toHaveValue('some free text');
@@ -831,7 +842,7 @@ describe('SearchQueryBuilder', function () {
831842

832843
// Pressing delete should remove all selected tokens
833844
await userEvent.keyboard('{Backspace}');
834-
expect(mockOnChange).toHaveBeenCalledWith('');
845+
expect(mockOnChange).toHaveBeenCalledWith('', expect.anything());
835846
});
836847

837848
it('focus goes to first input after ctrl+a and arrow left', async function () {
@@ -933,7 +944,7 @@ describe('SearchQueryBuilder', function () {
933944
await userEvent.keyboard('{Control>}x{/Control}');
934945

935946
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('browser.name:firefox');
936-
expect(mockOnChange).toHaveBeenCalledWith('');
947+
expect(mockOnChange).toHaveBeenCalledWith('', expect.anything());
937948
});
938949

939950
it('can undo last action with ctrl-z', async function () {
@@ -1118,7 +1129,7 @@ describe('SearchQueryBuilder', function () {
11181129
);
11191130
await userEvent.click(await screen.findByRole('option', {name: 'does not have'}));
11201131
await waitFor(() => {
1121-
expect(mockOnChange).toHaveBeenCalledWith('!has:key');
1132+
expect(mockOnChange).toHaveBeenCalledWith('!has:key', expect.anything());
11221133
});
11231134
expect(
11241135
within(
@@ -1311,7 +1322,10 @@ describe('SearchQueryBuilder', function () {
13111322

13121323
// Value should be surrounded by quotes and escaped
13131324
await waitFor(() => {
1314-
expect(mockOnChange).toHaveBeenCalledWith(`browser.name:${expected}`);
1325+
expect(mockOnChange).toHaveBeenCalledWith(
1326+
`browser.name:${expected}`,
1327+
expect.anything()
1328+
);
13151329
});
13161330
});
13171331

@@ -1826,7 +1840,7 @@ describe('SearchQueryBuilder', function () {
18261840
).toBeInTheDocument();
18271841

18281842
await waitFor(() => {
1829-
expect(mockOnChange).toHaveBeenCalledWith('foo age:-1h');
1843+
expect(mockOnChange).toHaveBeenCalledWith('foo age:-1h', expect.anything());
18301844
});
18311845
});
18321846

@@ -1854,7 +1868,7 @@ describe('SearchQueryBuilder', function () {
18541868
).toBeInTheDocument();
18551869

18561870
await waitFor(() => {
1857-
expect(mockOnChange).toHaveBeenCalledWith('foo age:+1h');
1871+
expect(mockOnChange).toHaveBeenCalledWith('foo age:+1h', expect.anything());
18581872
});
18591873
});
18601874

@@ -1876,7 +1890,7 @@ describe('SearchQueryBuilder', function () {
18761890
await userEvent.click(screen.getByRole('button', {name: 'Save'}));
18771891

18781892
await waitFor(() => {
1879-
expect(mockOnChange).toHaveBeenCalledWith('age:>2017-10-17');
1893+
expect(mockOnChange).toHaveBeenCalledWith('age:>2017-10-17', expect.anything());
18801894
});
18811895
});
18821896

@@ -1899,7 +1913,10 @@ describe('SearchQueryBuilder', function () {
18991913
await userEvent.click(await screen.findByRole('button', {name: 'Save'}));
19001914

19011915
await waitFor(() => {
1902-
expect(mockOnChange).toHaveBeenCalledWith('age:>2017-10-17T00:00:00Z');
1916+
expect(mockOnChange).toHaveBeenCalledWith(
1917+
'age:>2017-10-17T00:00:00Z',
1918+
expect.anything()
1919+
);
19031920
});
19041921
});
19051922

@@ -1923,7 +1940,10 @@ describe('SearchQueryBuilder', function () {
19231940
await userEvent.click(await screen.findByRole('button', {name: 'Save'}));
19241941

19251942
await waitFor(() => {
1926-
expect(mockOnChange).toHaveBeenCalledWith('age:>2017-10-17T00:00:00+00:00');
1943+
expect(mockOnChange).toHaveBeenCalledWith(
1944+
'age:>2017-10-17T00:00:00+00:00',
1945+
expect.anything()
1946+
);
19271947
});
19281948
});
19291949

static/app/components/searchQueryBuilder/index.tsx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,15 @@ import {useQueryBuilderState} from 'sentry/components/searchQueryBuilder/hooks/u
1212
import {PlainTextQueryInput} from 'sentry/components/searchQueryBuilder/plainTextQueryInput';
1313
import {TokenizedQueryGrid} from 'sentry/components/searchQueryBuilder/tokenizedQueryGrid';
1414
import {
15+
type CallbackSearchState,
1516
type FieldDefinitionGetter,
1617
type FilterKeySection,
1718
QueryInterfaceType,
1819
} from 'sentry/components/searchQueryBuilder/types';
19-
import {parseQueryBuilderValue} from 'sentry/components/searchQueryBuilder/utils';
20+
import {
21+
parseQueryBuilderValue,
22+
queryIsValid,
23+
} from 'sentry/components/searchQueryBuilder/utils';
2024
import type {SearchConfig} from 'sentry/components/searchSyntax/parser';
2125
import {IconClose, IconSearch} from 'sentry/icons';
2226
import {t} from 'sentry/locale';
@@ -26,6 +30,7 @@ import {getFieldDefinition} from 'sentry/utils/fields';
2630
import PanelProvider from 'sentry/utils/panelProvider';
2731
import {useDimensions} from 'sentry/utils/useDimensions';
2832
import {useEffectAfterFirstRender} from 'sentry/utils/useEffectAfterFirstRender';
33+
import usePrevious from 'sentry/utils/usePrevious';
2934

3035
export interface SearchQueryBuilderProps {
3136
/**
@@ -74,15 +79,15 @@ export interface SearchQueryBuilderProps {
7479
*/
7580
invalidMessages?: SearchConfig['invalidMessages'];
7681
label?: string;
77-
onBlur?: (query: string) => void;
82+
onBlur?: (query: string, state: CallbackSearchState) => void;
7883
/**
7984
* Called when the query value changes
8085
*/
81-
onChange?: (query: string) => void;
86+
onChange?: (query: string, state: CallbackSearchState) => void;
8287
/**
8388
* Called when the user presses enter
8489
*/
85-
onSearch?: (query: string) => void;
90+
onSearch?: (query: string, state: CallbackSearchState) => void;
8691
placeholder?: string;
8792
queryInterface?: QueryInterfaceType;
8893
/**
@@ -169,9 +174,12 @@ export function SearchQueryBuilder({
169174
dispatch({type: 'UPDATE_QUERY', query: initialQuery});
170175
}, [dispatch, initialQuery]);
171176

177+
const previousQuery = usePrevious(state.query);
172178
useEffectAfterFirstRender(() => {
173-
onChange?.(state.query);
174-
}, [onChange, state.query]);
179+
if (previousQuery !== state.query) {
180+
onChange?.(state.query, {parsedQuery, queryIsValid: queryIsValid(parsedQuery)});
181+
}
182+
}, [onChange, state.query, previousQuery, parsedQuery]);
175183

176184
const handleSearch = useHandleSearch({
177185
parsedQuery,
@@ -222,7 +230,9 @@ export function SearchQueryBuilder({
222230
<PanelProvider>
223231
<Wrapper
224232
className={className}
225-
onBlur={() => onBlur?.(state.query)}
233+
onBlur={() =>
234+
onBlur?.(state.query, {parsedQuery, queryIsValid: queryIsValid(parsedQuery)})
235+
}
226236
ref={wrapperRef}
227237
aria-disabled={disabled}
228238
>

static/app/components/searchQueryBuilder/types.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {ReactNode} from 'react';
22

3+
import type {ParseResult} from 'sentry/components/searchSyntax/parser';
34
import type {FieldDefinition} from 'sentry/utils/fields';
45

56
export type FilterKeySection = {
@@ -19,3 +20,8 @@ export type FocusOverride = {
1920
};
2021

2122
export type FieldDefinitionGetter = (key: string) => FieldDefinition | null;
23+
24+
export type CallbackSearchState = {
25+
parsedQuery: ParseResult | null;
26+
queryIsValid: boolean;
27+
};

static/app/components/searchQueryBuilder/utils.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,14 @@ export function tokenIsInvalid(token: TokenResult<Token>) {
165165
return Boolean(token.invalid);
166166
}
167167

168+
export function queryIsValid(parsedQuery: ParseResult | null) {
169+
if (!parsedQuery) {
170+
return false;
171+
}
172+
173+
return !parsedQuery.some(tokenIsInvalid);
174+
}
175+
168176
export function isDateToken(token: TokenResult<Token.FILTER>) {
169177
return [FilterType.DATE, FilterType.RELATIVE_DATE, FilterType.SPECIFIC_DATE].includes(
170178
token.filter

0 commit comments

Comments
 (0)