Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ type UpdateTokenValueAction = {
token: TokenResult<Token.FILTER>;
type: 'UPDATE_TOKEN_VALUE';
value: string;
op?: TermOperator;
};

type MultiSelectFilterValueAction = {
Expand Down Expand Up @@ -605,7 +606,8 @@ function replaceTokensWithText(
export function modifyFilterValue(
query: string,
token: TokenResult<Token.FILTER>,
newValue: string
newValue: string,
newOp?: TermOperator
): string {
if (isDateToken(token)) {
return modifyFilterValueDate(query, token, newValue);
Expand All @@ -614,7 +616,35 @@ export function modifyFilterValue(
// stop the user from entering multiple wildcards by themselves
newValue = newValue.replace(/\*\*+/g, '*');

return replaceQueryToken(query, token.value, newValue);
// No operator change — just replace the value (existing behavior)
if (newOp === undefined) {
return replaceQueryToken(query, token.value, newValue);
}

// Operator change — replace the entire filter token atomically
const negated =
newOp === TermOperator.NOT_EQUAL ||
newOp === TermOperator.DOES_NOT_CONTAIN ||
newOp === TermOperator.DOES_NOT_START_WITH ||
newOp === TermOperator.DOES_NOT_END_WITH;

let internalOp: TermOperator;
if (newOp === TermOperator.DOES_NOT_CONTAIN) {
internalOp = TermOperator.CONTAINS;
} else if (newOp === TermOperator.DOES_NOT_START_WITH) {
internalOp = TermOperator.STARTS_WITH;
} else if (newOp === TermOperator.DOES_NOT_END_WITH) {
internalOp = TermOperator.ENDS_WITH;
} else if (newOp === TermOperator.NOT_EQUAL) {
internalOp = TermOperator.DEFAULT;
} else {
internalOp = newOp;
}
Comment thread
nsdeschenes marked this conversation as resolved.
Outdated
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

const prefix = negated ? '!' : '';
const keyStr = stringifyToken(token.key);
const replacement = `${prefix}${keyStr}:${internalOp}${newValue}`;
return replaceQueryToken(query, token, replacement);
}

function updateFilterMultipleValues(
Expand Down Expand Up @@ -1049,7 +1079,7 @@ export function useQueryBuilderState({
case 'UPDATE_TOKEN_VALUE':
return {
...state,
query: modifyFilterValue(state.query, action.token, action.value),
query: modifyFilterValue(state.query, action.token, action.value, action.op),
};
case 'UPDATE_LOGIC_OPERATOR':
return updateLogicOperator(state, action);
Expand Down
31 changes: 27 additions & 4 deletions static/app/components/searchQueryBuilder/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1089,21 +1089,43 @@ describe('SearchQueryBuilder', () => {

await userEvent.click(await screen.findByRole('option', {name: 'Firefox'}));

// New token should have a value
// New token should have a value, and selecting from dropdown switches operator to "is"
expect(
screen.getByRole('row', {
name: `browser.name:${WildcardOperators.CONTAINS}Firefox`,
name: 'browser.name:Firefox',
})
).toBeInTheDocument();

// Now we call onChange
expect(mockOnChange).toHaveBeenCalledTimes(1);
expect(mockOnChange).toHaveBeenCalledWith(
`browser.name:${WildcardOperators.CONTAINS}Firefox`,
'browser.name:Firefox',
expect.anything()
);
});

it('does not switch operator to "is" when filter already has a value', async () => {
render(
<SearchQueryBuilder
{...defaultProps}
initialQuery={`browser.name:${WildcardOperators.CONTAINS}firefox`}
/>,
{organization: {features: ['search-query-builder-input-flow-changes']}}
);

await userEvent.click(
screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
);
await userEvent.click(await screen.findByRole('option', {name: 'Chrome'}));

// Operator should remain "contains" since there was already a value
expect(
await screen.findByRole('row', {
name: `browser.name:${WildcardOperators.CONTAINS}[firefox,Chrome]`,
})
).toBeInTheDocument();
});

it('can add free text by typing', async () => {
const mockOnSearch = jest.fn();
render(<SearchQueryBuilder {...defaultProps} onSearch={mockOnSearch} />);
Expand Down Expand Up @@ -1266,9 +1288,10 @@ describe('SearchQueryBuilder', () => {
await userEvent.keyboard('{enter}');
await userEvent.click(screen.getByRole('option', {name: '[Filtered]'}));

// Selecting from dropdown switches operator from contains to "is"
expect(
await screen.findByRole('row', {
name: `message:${WildcardOperators.CONTAINS}"[Filtered]"`,
name: 'message:"[Filtered]"',
})
).toBeInTheDocument();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,7 @@
);

const updateFilterValue = useCallback(
(value: string) => {
(value: string, op?: TermOperator) => {
if (token.filter === FilterType.HAS) {
const suggested = getSuggestedFilterKey(value);
if (suggested) {
Expand Down Expand Up @@ -793,6 +793,7 @@
type: 'UPDATE_TOKEN_VALUE',
token,
value: newValue,
op,
});

if (newValue && newValue !== '""' && !ctrlKeyPressed) {
Expand All @@ -809,6 +810,7 @@
getFilterValueType(token, fieldDefinition),
replaceCommaSeparatedValue(inputValue, selectionIndex, escapeTagValue(value))
),
op,
});

if (!ctrlKeyPressed) {
Expand All @@ -819,6 +821,7 @@
type: 'UPDATE_TOKEN_VALUE',
token,
value: cleanedValue,
op,
});
onCommit();
}
Expand Down Expand Up @@ -860,7 +863,13 @@
return;
}

updateFilterValue(value);
// When selecting from dropdown with no existing value, switch from "contains" to "is"
let newOp: TermOperator | undefined;
if (token.operator === TermOperator.CONTAINS && !token.value.value) {

Check failure on line 868 in static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx

View workflow job for this annotation

GitHub Actions / @typescript/native-preview

Property 'value' does not exist on type '({ text: string; location: LocationRange; type: Token.VALUE_TEXT_LIST; items: { separator: string; value: { text: string; location: LocationRange; type: Token.VALUE_TEXT; value: string; quoted: boolean; } | null; }[]; } & { ...; }) | ({ ...; } & { ...; })'.

Check failure on line 868 in static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx

View workflow job for this annotation

GitHub Actions / typescript

Property 'value' does not exist on type '({ type: Token.VALUE_TEXT; value: string; quoted: boolean; text: string; location: LocationRange; } & { type: Token.VALUE_TEXT; }) | ({ type: Token.VALUE_TEXT_LIST; items: { separator: string; value: { ...; } | null; }[]; text: string; location: LocationRange; } & { ...; })'.
Comment thread
sentry[bot] marked this conversation as resolved.
Outdated
newOp = token.negated ? TermOperator.NOT_EQUAL : TermOperator.DEFAULT;
}

updateFilterValue(value, newOp);
trackAnalytics('search.value_autocompleted', {
...analyticsData,
filter_value: value,
Expand Down
Loading