Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -280,6 +281,32 @@ function deleteQueryTokens(
};
}

function termOperatorToInternal(op: TermOperator): {
internalOp: TermOperator;
negated: boolean;
} {
const negated =
op === TermOperator.NOT_EQUAL ||
op === TermOperator.DOES_NOT_CONTAIN ||
op === TermOperator.DOES_NOT_START_WITH ||
op === TermOperator.DOES_NOT_END_WITH;

let internalOp: TermOperator;
if (op === TermOperator.DOES_NOT_CONTAIN) {
internalOp = TermOperator.CONTAINS;
} else if (op === TermOperator.DOES_NOT_START_WITH) {
internalOp = TermOperator.STARTS_WITH;
} else if (op === TermOperator.DOES_NOT_END_WITH) {
internalOp = TermOperator.ENDS_WITH;
} else if (op === TermOperator.NOT_EQUAL) {
internalOp = TermOperator.DEFAULT;
} else {
internalOp = op;
}

return {negated, internalOp};
}

export function modifyFilterOperatorQuery(
query: string,
token: TokenResult<Token.FILTER>,
Expand All @@ -289,23 +316,10 @@ export function modifyFilterOperatorQuery(
return modifyFilterOperatorDate(query, token, newOperator);
}

const {negated, internalOp} = termOperatorToInternal(newOperator);
const newToken: TokenResult<Token.FILTER> = {...token};
newToken.negated =
newOperator === TermOperator.NOT_EQUAL ||
newOperator === TermOperator.DOES_NOT_CONTAIN ||
newOperator === TermOperator.DOES_NOT_START_WITH ||
newOperator === TermOperator.DOES_NOT_END_WITH;

if (newOperator === TermOperator.DOES_NOT_CONTAIN) {
newToken.operator = TermOperator.CONTAINS;
} else if (newOperator === TermOperator.DOES_NOT_START_WITH) {
newToken.operator = TermOperator.STARTS_WITH;
} else if (newOperator === TermOperator.DOES_NOT_END_WITH) {
newToken.operator = TermOperator.ENDS_WITH;
} else {
newToken.operator =
newOperator === TermOperator.NOT_EQUAL ? TermOperator.DEFAULT : newOperator;
}
newToken.negated = negated;
newToken.operator = internalOp;

return replaceQueryToken(query, token, stringifyToken(newToken));
}
Expand Down Expand Up @@ -605,7 +619,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 +629,18 @@ 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, internalOp} = termOperatorToInternal(newOp);

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 +1075,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 @@ export function SearchQueryBuilderValueCombobox({
);

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 @@ export function SearchQueryBuilderValueCombobox({
type: 'UPDATE_TOKEN_VALUE',
token,
value: newValue,
op,
});

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

if (!ctrlKeyPressed) {
Expand All @@ -819,6 +821,7 @@ export function SearchQueryBuilderValueCombobox({
type: 'UPDATE_TOKEN_VALUE',
token,
value: cleanedValue,
op,
});
onCommit();
}
Expand Down Expand Up @@ -860,7 +863,17 @@ export function SearchQueryBuilderValueCombobox({
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.type === Token.VALUE_TEXT &&
!token.value.value
) {
newOp = token.negated ? TermOperator.NOT_EQUAL : TermOperator.DEFAULT;
}

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