Skip to content

Commit 020fc0c

Browse files
authored
feat(query-builder): Add ability to edit filter operators (#69329)
Closes #69262 Adds a dropdown to the filter that, when clicked, can edit the operator. To modify the query, we modify the token object with the new operator value and use a new `stringifyToken()` function to create the new filter text. We then replace the old token text with the new.
1 parent 5455611 commit 020fc0c

File tree

5 files changed

+208
-36
lines changed

5 files changed

+208
-36
lines changed

static/app/components/searchQueryBuilder/filter.tsx

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import {useRef} from 'react';
1+
import {useMemo, useRef} from 'react';
22
import styled from '@emotion/styled';
33

4+
import {DropdownMenu, type MenuItemProps} from 'sentry/components/dropdownMenu';
45
import InteractionStateLayer from 'sentry/components/interactionStateLayer';
56
import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context';
7+
import {getValidOpsForFilter} from 'sentry/components/searchQueryBuilder/utils';
68
import {
79
TermOperator,
810
type Token,
@@ -35,13 +37,37 @@ const getOpLabel = (token: TokenResult<Token.FILTER>) => {
3537
};
3638

3739
function FilterOperator({token}: SearchQueryTokenProps) {
38-
// TODO(malwilley): Add edit functionality
40+
const {dispatch} = useSearchQueryBuilder();
41+
42+
const items: MenuItemProps[] = useMemo(() => {
43+
return getValidOpsForFilter(token).map(op => ({
44+
key: op,
45+
label: OP_LABELS[op] ?? op,
46+
onAction: val => {
47+
dispatch({
48+
type: 'UPDATE_FILTER_OP',
49+
token,
50+
op: val as TermOperator,
51+
});
52+
},
53+
}));
54+
}, [dispatch, token]);
3955

4056
return (
41-
<OpDiv tabIndex={-1} role="gridcell" aria-label={t('Edit token operator')}>
42-
<InteractionStateLayer />
43-
{getOpLabel(token)}
44-
</OpDiv>
57+
<DropdownMenu
58+
trigger={triggerProps => (
59+
<OpDiv
60+
tabIndex={-1}
61+
role="gridcell"
62+
aria-label={t('Edit token operator')}
63+
{...triggerProps}
64+
>
65+
<InteractionStateLayer />
66+
{getOpLabel(token)}
67+
</OpDiv>
68+
)}
69+
items={items}
70+
/>
4571
);
4672
}
4773

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

Lines changed: 55 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -33,30 +33,60 @@ describe('SearchQueryBuilder', function () {
3333
label: 'Query Builder',
3434
};
3535

36-
it('can remove a token by clicking the delete button', async function () {
37-
render(
38-
<SearchQueryBuilder
39-
{...defaultProps}
40-
initialQuery="browser.name:firefox custom_tag_name:123"
41-
/>
42-
);
43-
44-
expect(screen.getByRole('row', {name: 'browser.name:firefox'})).toBeInTheDocument();
45-
expect(screen.getByRole('row', {name: 'custom_tag_name:123'})).toBeInTheDocument();
46-
47-
await userEvent.click(
48-
within(screen.getByRole('row', {name: 'browser.name:firefox'})).getByRole(
49-
'gridcell',
50-
{name: 'Remove token'}
51-
)
52-
);
53-
54-
// Browser name token should be removed
55-
expect(
56-
screen.queryByRole('row', {name: 'browser.name:firefox'})
57-
).not.toBeInTheDocument();
58-
59-
// Custom tag token should still be present
60-
expect(screen.getByRole('row', {name: 'custom_tag_name:123'})).toBeInTheDocument();
36+
describe('mouse interactions', function () {
37+
it('can remove a token by clicking the delete button', async function () {
38+
render(
39+
<SearchQueryBuilder
40+
{...defaultProps}
41+
initialQuery="browser.name:firefox custom_tag_name:123"
42+
/>
43+
);
44+
45+
expect(screen.getByRole('row', {name: 'browser.name:firefox'})).toBeInTheDocument();
46+
expect(screen.getByRole('row', {name: 'custom_tag_name:123'})).toBeInTheDocument();
47+
48+
await userEvent.click(
49+
within(screen.getByRole('row', {name: 'browser.name:firefox'})).getByRole(
50+
'gridcell',
51+
{name: 'Remove token'}
52+
)
53+
);
54+
55+
// Browser name token should be removed
56+
expect(
57+
screen.queryByRole('row', {name: 'browser.name:firefox'})
58+
).not.toBeInTheDocument();
59+
60+
// Custom tag token should still be present
61+
expect(screen.getByRole('row', {name: 'custom_tag_name:123'})).toBeInTheDocument();
62+
});
63+
64+
it('can modify the operator by clicking into it', async function () {
65+
render(
66+
<SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
67+
);
68+
69+
// Should display as "is" to start
70+
expect(
71+
within(screen.getByRole('gridcell', {name: 'Edit token operator'})).getByText(
72+
'is'
73+
)
74+
).toBeInTheDocument();
75+
76+
await userEvent.click(screen.getByRole('gridcell', {name: 'Edit token operator'}));
77+
await userEvent.click(screen.getByRole('menuitemradio', {name: 'is not'}));
78+
79+
// Token should be modified to be negated
80+
expect(
81+
screen.getByRole('row', {name: '!browser.name:firefox'})
82+
).toBeInTheDocument();
83+
84+
// Should now have "is not" label
85+
expect(
86+
within(screen.getByRole('gridcell', {name: 'Edit token operator'})).getByText(
87+
'is not'
88+
)
89+
).toBeInTheDocument();
90+
});
6191
});
6292
});

static/app/components/searchQueryBuilder/useQueryBuilderState.tsx

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import {type Reducer, useCallback, useReducer} from 'react';
22

3-
import type {
4-
ParseResultToken,
5-
Token,
6-
TokenResult,
3+
import {
4+
type ParseResultToken,
5+
TermOperator,
6+
type Token,
7+
type TokenResult,
78
} from 'sentry/components/searchSyntax/parser';
9+
import {stringifyToken} from 'sentry/components/searchSyntax/utils';
810

911
type QueryBuilderState = {
1012
focus: null; // TODO(malwilley): Implement focus state
@@ -16,7 +18,13 @@ type DeleteTokenAction = {
1618
type: 'DELETE_TOKEN';
1719
};
1820

19-
export type QueryBuilderActions = DeleteTokenAction;
21+
type UpdateFilterOpAction = {
22+
op: TermOperator;
23+
token: TokenResult<Token.FILTER>;
24+
type: 'UPDATE_FILTER_OP';
25+
};
26+
27+
export type QueryBuilderActions = DeleteTokenAction | UpdateFilterOpAction;
2028

2129
function removeQueryToken(query: string, token: TokenResult<Token>): string {
2230
return (
@@ -25,6 +33,23 @@ function removeQueryToken(query: string, token: TokenResult<Token>): string {
2533
);
2634
}
2735

36+
function modifyFilterOperator(
37+
query: string,
38+
token: TokenResult<Token.FILTER>,
39+
newOperator: TermOperator
40+
): string {
41+
const isNotEqual = newOperator === TermOperator.NOT_EQUAL;
42+
43+
token.operator = isNotEqual ? TermOperator.DEFAULT : newOperator;
44+
token.negated = isNotEqual;
45+
46+
return (
47+
query.substring(0, token.location.start.offset) +
48+
stringifyToken(token) +
49+
query.substring(token.location.end.offset)
50+
);
51+
}
52+
2853
export function useQueryBuilderState({initialQuery}: {initialQuery: string}) {
2954
const initialState: QueryBuilderState = {query: initialQuery, focus: null};
3055

@@ -37,6 +62,11 @@ export function useQueryBuilderState({initialQuery}: {initialQuery: string}) {
3762
query: removeQueryToken(state.query, action.token),
3863
focus: null,
3964
};
65+
case 'UPDATE_FILTER_OP':
66+
return {
67+
...state,
68+
query: modifyFilterOperator(state.query, action.token, action.op),
69+
};
4070
default:
4171
return state;
4272
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {
2+
filterTypeConfig,
3+
interchangeableFilterOperators,
4+
type TermOperator,
5+
type Token,
6+
type TokenResult,
7+
} from 'sentry/components/searchSyntax/parser';
8+
9+
export function getValidOpsForFilter(
10+
filterToken: TokenResult<Token.FILTER>
11+
): readonly TermOperator[] {
12+
// If the token is invalid we want to use the possible expected types as our filter type
13+
const validTypes = filterToken.invalid?.expectedType ?? [filterToken.filter];
14+
15+
// Determine any interchangeable filter types for our valid types
16+
const interchangeableTypes = validTypes.map(
17+
type => interchangeableFilterOperators[type] ?? []
18+
);
19+
20+
// Combine all types
21+
const allValidTypes = [...new Set([...validTypes, ...interchangeableTypes.flat()])];
22+
23+
// Find all valid operations
24+
const validOps = new Set<TermOperator>(
25+
allValidTypes.flatMap(type => filterTypeConfig[type].validOps)
26+
);
27+
28+
return [...validOps];
29+
}

static/app/components/searchSyntax/utils.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,60 @@ export function isWithinToken(
241241
export function isOperator(value: string) {
242242
return allOperators.some(op => op === value);
243243
}
244+
245+
function stringifyTokenFilter(token: TokenResult<Token.FILTER>) {
246+
let stringifiedToken = '';
247+
248+
if (token.negated) {
249+
stringifiedToken += '!';
250+
}
251+
252+
stringifiedToken += stringifyToken(token.key);
253+
stringifiedToken += ':';
254+
stringifiedToken += token.operator;
255+
stringifiedToken += stringifyToken(token.value);
256+
257+
return stringifiedToken;
258+
}
259+
260+
export function stringifyToken(token: TokenResult<Token>) {
261+
switch (token.type) {
262+
case Token.FREE_TEXT:
263+
case Token.SPACES:
264+
return token.value;
265+
case Token.FILTER:
266+
return stringifyTokenFilter(token);
267+
case Token.LOGIC_GROUP:
268+
return `(${token.inner.map(innerToken => stringifyToken(innerToken)).join(' ')})`;
269+
case Token.LOGIC_BOOLEAN:
270+
return token.value;
271+
case Token.VALUE_TEXT_LIST:
272+
return token.items.map(v => v.value).join(',');
273+
case Token.VALUE_NUMBER_LIST:
274+
return token.items
275+
.map(item => (item.value ? item.value.value + item.value.unit : ''))
276+
.filter(str => str.length > 0)
277+
.join(', ');
278+
case Token.KEY_SIMPLE:
279+
return token.value;
280+
case Token.KEY_AGGREGATE:
281+
return token.text;
282+
case Token.KEY_AGGREGATE_ARGS:
283+
return token.text;
284+
case Token.KEY_AGGREGATE_PARAMS:
285+
return token.text;
286+
case Token.KEY_EXPLICIT_TAG:
287+
return `${token.prefix}[${token.key.value}]`;
288+
case Token.VALUE_BOOLEAN:
289+
case Token.VALUE_DURATION:
290+
case Token.VALUE_ISO_8601_DATE:
291+
case Token.VALUE_PERCENTAGE:
292+
case Token.VALUE_RELATIVE_DATE:
293+
case Token.VALUE_SIZE:
294+
case Token.VALUE_TEXT:
295+
case Token.VALUE_NUMBER:
296+
return token.value;
297+
default:
298+
return '';
299+
}
300+
}

0 commit comments

Comments
 (0)