From 9ef9bd3e715b497b3eab8c4c890a9ca543dbb778 Mon Sep 17 00:00:00 2001 From: Malachi Willey Date: Mon, 17 Jun 2024 09:48:01 -0700 Subject: [PATCH] feat(query-builder): Make date filters more user friendly --- .../searchQueryBuilder/filterOperator.tsx | 77 +++++++++-- .../filterValueParser/date/grammar.pegjs | 24 ++++ .../filterValueParser/date/parser.tsx | 23 ++++ .../searchQueryBuilder/index.spec.tsx | 119 +++++++++++++++-- .../searchQueryBuilder/index.stories.tsx | 5 - .../useQueryBuilderState.tsx | 79 +++++++++++- .../components/searchQueryBuilder/utils.tsx | 12 +- .../searchQueryBuilder/valueCombobox.tsx | 122 +++++++++--------- static/app/components/searchSyntax/utils.tsx | 3 +- 9 files changed, 372 insertions(+), 92 deletions(-) create mode 100644 static/app/components/searchQueryBuilder/filterValueParser/date/grammar.pegjs create mode 100644 static/app/components/searchQueryBuilder/filterValueParser/date/parser.tsx diff --git a/static/app/components/searchQueryBuilder/filterOperator.tsx b/static/app/components/searchQueryBuilder/filterOperator.tsx index 13986252066875..5581e1f0496cf1 100644 --- a/static/app/components/searchQueryBuilder/filterOperator.tsx +++ b/static/app/components/searchQueryBuilder/filterOperator.tsx @@ -8,8 +8,12 @@ import {CompactSelect, type SelectOption} from 'sentry/components/compactSelect' import InteractionStateLayer from 'sentry/components/interactionStateLayer'; import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context'; import {useFilterButtonProps} from 'sentry/components/searchQueryBuilder/useFilterButtonProps'; -import {getValidOpsForFilter} from 'sentry/components/searchQueryBuilder/utils'; import { + getValidOpsForFilter, + isDateToken, +} from 'sentry/components/searchQueryBuilder/utils'; +import { + FilterType, type ParseResultToken, TermOperator, type Token, @@ -33,6 +37,36 @@ const OP_LABELS = { [TermOperator.NOT_EQUAL]: 'is not', }; +const DATE_OP_LABELS = { + [TermOperator.GREATER_THAN]: 'is after', + [TermOperator.GREATER_THAN_EQUAL]: 'is on or after', + [TermOperator.LESS_THAN]: 'is before', + [TermOperator.LESS_THAN_EQUAL]: 'is on or before', + [TermOperator.EQUAL]: 'is', +}; + +const DATE_OPTIONS: TermOperator[] = [ + TermOperator.GREATER_THAN, + TermOperator.GREATER_THAN_EQUAL, + TermOperator.LESS_THAN, + TermOperator.LESS_THAN_EQUAL, + TermOperator.EQUAL, +]; + +function getOperatorFromDateToken(token: TokenResult) { + switch (token.filter) { + case FilterType.DATE: + case FilterType.SPECIFIC_DATE: + return token.operator; + case FilterType.RELATIVE_DATE: + return token.value.sign === '+' + ? TermOperator.LESS_THAN + : TermOperator.GREATER_THAN; + default: + return TermOperator.DEFAULT; + } +} + function getTermOperatorFromToken(token: TokenResult) { if (token.negated) { return TermOperator.NOT_EQUAL; @@ -41,22 +75,45 @@ function getTermOperatorFromToken(token: TokenResult) { return token.operator; } -export function FilterOperator({token, state, item}: FilterOperatorProps) { - const {dispatch} = useSearchQueryBuilder(); - const operator = getTermOperatorFromToken(token); - const label = OP_LABELS[operator] ?? operator; - const filterButtonProps = useFilterButtonProps({state, item}); +function getOperatorInfo(token: TokenResult): { + label: string; + operator: TermOperator; + options: SelectOption[]; +} { + if (isDateToken(token)) { + const operator = getOperatorFromDateToken(token); + return { + operator, + label: DATE_OP_LABELS[operator] ?? operator, + options: DATE_OPTIONS.map( + (op): SelectOption => ({ + value: op, + label: DATE_OP_LABELS[op] ?? op, + }) + ), + }; + } - const options = useMemo[]>(() => { - return getValidOpsForFilter(token) + const operator = getTermOperatorFromToken(token); + return { + operator, + label: OP_LABELS[operator] ?? operator, + options: getValidOpsForFilter(token) .filter(op => op !== TermOperator.EQUAL) .map( (op): SelectOption => ({ value: op, label: OP_LABELS[op] ?? op, }) - ); - }, [token]); + ), + }; +} + +export function FilterOperator({token, state, item}: FilterOperatorProps) { + const {dispatch} = useSearchQueryBuilder(); + const filterButtonProps = useFilterButtonProps({state, item}); + + const {operator, label, options} = useMemo(() => getOperatorInfo(token), [token]); return ( | null { + try { + return grammar.parse(query, {TokenConverter}); + } catch (e) { + return null; + } +} diff --git a/static/app/components/searchQueryBuilder/index.spec.tsx b/static/app/components/searchQueryBuilder/index.spec.tsx index a72980bc371da6..26ba2db484ec46 100644 --- a/static/app/components/searchQueryBuilder/index.spec.tsx +++ b/static/app/components/searchQueryBuilder/index.spec.tsx @@ -55,6 +55,7 @@ const FITLER_KEY_SECTIONS: FilterKeySection[] = [ name: 'is', alias: 'issue.status', predefined: true, + values: ['resolved', 'unresolved', 'ignored'], }, { key: FieldKey.TIMES_SEEN, @@ -308,32 +309,32 @@ describe('SearchQueryBuilder', function () { }); it('can modify the value by clicking into it (single-select)', async function () { - // `age` is a duration filter which only accepts single values - render(); + // `is` only accepts single values + render(); - // Should display as "-1d" to start + // Should display as "unresolved" to start expect( - within( - screen.getByRole('button', {name: 'Edit value for filter: age'}) - ).getByText('-1d') + within(screen.getByRole('button', {name: 'Edit value for filter: is'})).getByText( + 'unresolved' + ) ).toBeInTheDocument(); await userEvent.click( - screen.getByRole('button', {name: 'Edit value for filter: age'}) + screen.getByRole('button', {name: 'Edit value for filter: is'}) ); // Should have placeholder text of previous value expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveAttribute( 'placeholder', - '-1d' + 'unresolved' ); - // Clicking the "-14d" option should update the value - await userEvent.click(await screen.findByRole('option', {name: '-14d'})); - expect(screen.getByRole('row', {name: 'age:-14d'})).toBeInTheDocument(); + // Clicking the "resolved" option should update the value + await userEvent.click(await screen.findByRole('option', {name: 'resolved'})); + expect(screen.getByRole('row', {name: 'is:resolved'})).toBeInTheDocument(); expect( - within( - screen.getByRole('button', {name: 'Edit value for filter: age'}) - ).getByText('-14d') + within(screen.getByRole('button', {name: 'Edit value for filter: is'})).getByText( + 'resolved' + ) ).toBeInTheDocument(); }); @@ -964,5 +965,95 @@ describe('SearchQueryBuilder', function () { ).toBeInTheDocument(); }); }); + + describe('date', function () { + it('new date filters start with a value', async function () { + render(); + await userEvent.click(screen.getByRole('grid')); + await userEvent.keyboard('age{ArrowDown}{Enter}'); + + // Should start with a relative date value + expect(await screen.findByRole('row', {name: 'age:-24h'})).toBeInTheDocument(); + }); + + it('does not allow invalid values', async function () { + render(); + await userEvent.click( + screen.getByRole('button', {name: 'Edit value for filter: age'}) + ); + await userEvent.keyboard('a{Enter}'); + + // Should have the same value because "a" is not a date value + expect(screen.getByRole('row', {name: 'age:-24h'})).toBeInTheDocument(); + }); + + it('shows default date suggestions', async function () { + render(); + await userEvent.click( + screen.getByRole('button', {name: 'Edit value for filter: age'}) + ); + await userEvent.click(await screen.findByRole('option', {name: '1 hour ago'})); + expect(screen.getByRole('row', {name: 'age:-1h'})).toBeInTheDocument(); + }); + + it('shows date suggestions when typing', async function () { + render(); + await userEvent.click( + screen.getByRole('button', {name: 'Edit value for filter: age'}) + ); + + // Typing "7" should show suggestions for 7 minutes, hours, days, and weeks + await userEvent.keyboard('7'); + await screen.findByRole('option', {name: '7 minutes ago'}); + expect(screen.getByRole('option', {name: '7 hours ago'})).toBeInTheDocument(); + expect(screen.getByRole('option', {name: '7 days ago'})).toBeInTheDocument(); + expect(screen.getByRole('option', {name: '7 weeks ago'})).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('option', {name: '7 weeks ago'})); + expect(screen.getByRole('row', {name: 'age:-7w'})).toBeInTheDocument(); + }); + + it('can search before a relative date', async function () { + render(); + await userEvent.click( + screen.getByRole('button', {name: 'Edit operator for filter: age'}) + ); + await userEvent.click(await screen.findByRole('option', {name: 'is before'})); + + // Should flip from "-" to "+" + expect(await screen.findByRole('row', {name: 'age:+24h'})).toBeInTheDocument(); + }); + + it('switches to an absolute date when choosing operator with equality', async function () { + render(); + await userEvent.click( + screen.getByRole('button', {name: 'Edit operator for filter: age'}) + ); + await userEvent.click( + await screen.findByRole('option', {name: 'is on or after'}) + ); + + // Changes operator and fills in the current date (ISO format) + expect( + await screen.findByRole('row', {name: 'age:>=2017-10-17T02:41:20.000Z'}) + ).toBeInTheDocument(); + }); + + it('changes operator when selecting a relative date', async function () { + render(); + await userEvent.click( + screen.getByRole('button', {name: 'Edit value for filter: age'}) + ); + await userEvent.click(await screen.findByRole('option', {name: '1 hour ago'})); + + // Because relative dates only work with ":", should change the operator to "is after" + expect( + within( + screen.getByRole('button', {name: 'Edit operator for filter: age'}) + ).getByText('is after') + ).toBeInTheDocument(); + expect(await screen.findByRole('row', {name: 'age:-1h'})).toBeInTheDocument(); + }); + }); }); }); diff --git a/static/app/components/searchQueryBuilder/index.stories.tsx b/static/app/components/searchQueryBuilder/index.stories.tsx index cc31ab61db8e07..60ad51706f9c79 100644 --- a/static/app/components/searchQueryBuilder/index.stories.tsx +++ b/static/app/components/searchQueryBuilder/index.stories.tsx @@ -41,11 +41,6 @@ const FITLER_KEY_SECTIONS: FilterKeySection[] = [ predefined: true, values: ['Chrome', 'Firefox', 'Safari', 'Edge'], }, - { - key: FieldKey.LAST_SEEN, - name: 'Last Seen', - kind: FieldKind.FIELD, - }, ], }, { diff --git a/static/app/components/searchQueryBuilder/useQueryBuilderState.tsx b/static/app/components/searchQueryBuilder/useQueryBuilderState.tsx index d92fd8e153818d..3d456111eee9c6 100644 --- a/static/app/components/searchQueryBuilder/useQueryBuilderState.tsx +++ b/static/app/components/searchQueryBuilder/useQueryBuilderState.tsx @@ -1,11 +1,14 @@ import {type Reducer, useCallback, useReducer} from 'react'; +import {parseFilterValueDate} from 'sentry/components/searchQueryBuilder/filterValueParser/date/parser'; import type {FocusOverride} from 'sentry/components/searchQueryBuilder/types'; import { + isDateToken, makeTokenKey, parseQueryBuilderValue, } from 'sentry/components/searchQueryBuilder/utils'; import { + FilterType, type ParseResultToken, TermOperator, Token, @@ -53,7 +56,7 @@ type UpdateFilterOpAction = { }; type UpdateTokenValueAction = { - token: TokenResult; + token: TokenResult; type: 'UPDATE_TOKEN_VALUE'; value: string; }; @@ -93,6 +96,10 @@ function modifyFilterOperator( token: TokenResult, newOperator: TermOperator ): string { + if (isDateToken(token)) { + return modifyFilterOperatorDate(query, token, newOperator); + } + const isNotEqual = newOperator === TermOperator.NOT_EQUAL; const newToken: TokenResult = {...token}; @@ -102,6 +109,62 @@ function modifyFilterOperator( return replaceQueryToken(query, token, stringifyToken(newToken)); } +function modifyFilterOperatorDate( + query: string, + token: TokenResult, + newOperator: TermOperator +): string { + switch (newOperator) { + case TermOperator.GREATER_THAN: + case TermOperator.LESS_THAN: { + if (token.filter === FilterType.RELATIVE_DATE) { + token.value.sign = newOperator === TermOperator.GREATER_THAN ? '-' : '+'; + } else if ( + token.filter === FilterType.SPECIFIC_DATE || + token.filter === FilterType.DATE + ) { + token.operator = newOperator; + } + return replaceQueryToken(query, token, stringifyToken(token)); + } + + // The "equal" and "or equal to" operators require a specific date + case TermOperator.EQUAL: + case TermOperator.GREATER_THAN_EQUAL: + case TermOperator.LESS_THAN_EQUAL: + // If it's a relative date, modify the operator and generate an ISO timestamp + if (token.filter === FilterType.RELATIVE_DATE) { + const generatedIsoDateStr = new Date().toISOString(); + const newTokenStr = `${token.key.text}:${newOperator}${generatedIsoDateStr}`; + return replaceQueryToken(query, token, newTokenStr); + } + return modifyFilterOperator(query, token, newOperator); + default: + return replaceQueryToken(query, token, newOperator); + } +} + +function modifyFilterValueDate( + query: string, + token: TokenResult, + newValue: string +): string { + const parsedValue = parseFilterValueDate(newValue); + + if (!parsedValue) { + return query; + } + + if (token.value.type === parsedValue?.type) { + return replaceQueryToken(query, token.value, newValue); + } + + if (parsedValue.type === Token.VALUE_ISO_8601_DATE) { + return replaceQueryToken(query, token.value, newValue); + } + return `${token.key.text}:${newValue}`; +} + function replaceQueryToken( query: string, token: TokenResult, @@ -168,6 +231,18 @@ function pasteFreeText( }; } +function modifyFilterValue( + query: string, + token: TokenResult, + newValue: string +): string { + if (isDateToken(token)) { + return modifyFilterValueDate(query, token, newValue); + } + + return replaceQueryToken(query, token.value, newValue); +} + function updateFilterMultipleValues( state: QueryBuilderState, token: TokenResult, @@ -274,7 +349,7 @@ export function useQueryBuilderState({initialQuery}: {initialQuery: string}) { case 'UPDATE_TOKEN_VALUE': return { ...state, - query: replaceQueryToken(state.query, action.token, action.value), + query: modifyFilterValue(state.query, action.token, action.value), }; case 'TOGGLE_FILTER_VALUE': return multiSelectTokenValue(state, action); diff --git a/static/app/components/searchQueryBuilder/utils.tsx b/static/app/components/searchQueryBuilder/utils.tsx index cb5a701e6baa2e..07528d9cf06cf4 100644 --- a/static/app/components/searchQueryBuilder/utils.tsx +++ b/static/app/components/searchQueryBuilder/utils.tsx @@ -4,6 +4,7 @@ import type {ListState} from '@react-stately/list'; import type {Node} from '@react-types/shared'; import { + FilterType, filterTypeConfig, interchangeableFilterOperators, type ParseResult, @@ -14,6 +15,7 @@ import { Token, type TokenResult, } from 'sentry/components/searchSyntax/parser'; +import {t} from 'sentry/locale'; import type {Tag, TagCollection} from 'sentry/types'; import {escapeDoubleQuotes} from 'sentry/utils'; import {FieldValueType, getFieldDefinition} from 'sentry/utils/fields'; @@ -80,7 +82,7 @@ export function parseQueryBuilderValue( */ export function makeTokenKey(token: ParseResultToken, allTokens: ParseResult | null) { const tokenTypeIndex = - allTokens?.filter(t => t.type === token.type).indexOf(token) ?? 0; + allTokens?.filter(tk => tk.type === token.type).indexOf(token) ?? 0; return `${token.type}:${tokenTypeIndex}`; } @@ -166,6 +168,8 @@ export function formatFilterValue(token: TokenResult['value']): st switch (token.type) { case Token.VALUE_TEXT: return unescapeTagValue(token.value); + case Token.VALUE_RELATIVE_DATE: + return t('%s', `${token.value}${token.unit} ago`); default: return token.text; } @@ -202,3 +206,9 @@ export function useShiftFocusToChild( shiftFocusProps: {onFocus}, }; } + +export function isDateToken(token: TokenResult) { + return [FilterType.DATE, FilterType.RELATIVE_DATE, FilterType.SPECIFIC_DATE].includes( + token.filter + ); +} diff --git a/static/app/components/searchQueryBuilder/valueCombobox.tsx b/static/app/components/searchQueryBuilder/valueCombobox.tsx index 312401893e6b23..019c0f9ebf8442 100644 --- a/static/app/components/searchQueryBuilder/valueCombobox.tsx +++ b/static/app/components/searchQueryBuilder/valueCombobox.tsx @@ -9,6 +9,7 @@ import type {SelectOptionWithKey} from 'sentry/components/compactSelect/types'; import {getItemsWithKeys} from 'sentry/components/compactSelect/utils'; import {SearchQueryBuilderCombobox} from 'sentry/components/searchQueryBuilder/combobox'; import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context'; +import {parseFilterValueDate} from 'sentry/components/searchQueryBuilder/filterValueParser/date/parser'; import { escapeTagValue, formatFilterValue, @@ -40,6 +41,7 @@ type SearchQueryValueBuilderProps = { type SuggestionItem = { value: string; description?: ReactNode; + label?: string; }; type SuggestionSection = { @@ -53,10 +55,11 @@ type SuggestionSectionItem = { }; const NUMERIC_REGEX = /^-?\d+(\.\d+)?$/; -const RELATIVE_DATE_REGEX = /^([+-]?)(\d+)([mhdw]?)$/; const FILTER_VALUE_NUMERIC = /^-?\d+(\.\d+)?[kmb]?$/i; const FILTER_VALUE_INT = /^-?\d+[kmb]?$/i; +const RELATIVE_DATE_INPUT_REGEX = /^(\d+)\s*([mhdw]?)/; + function isNumeric(value: string) { return NUMERIC_REGEX.test(value); } @@ -69,7 +72,6 @@ function isStringFilterValues( const NUMERIC_UNITS = ['k', 'm', 'b'] as const; const RELATIVE_DATE_UNITS = ['m', 'h', 'd', 'w'] as const; -const RELATIVE_DATE_SIGNS = ['-', '+'] as const; const DURATION_UNITS = ['ms', 's', 'm', 'h', 'd', 'w'] as const; const DEFAULT_NUMERIC_SUGGESTIONS: SuggestionSection[] = [ @@ -93,53 +95,45 @@ const DEFAULT_BOOLEAN_SUGGESTIONS: SuggestionSection[] = [ }, ]; -const DEFAULT_DATE_SUGGESTIONS: SuggestionSection[] = [ - { - sectionText: '', - suggestions: [ - {value: '-1h', description: t('Last hour')}, - {value: '-24h', description: t('Last 24 hours')}, - {value: '-7d', description: t('Last 7 days')}, - {value: '-14d', description: t('Last 14 days')}, - {value: '-30d', description: t('Last 30 days')}, - {value: '+1d', description: t('More than 1 day ago')}, - ], - }, -]; - -const makeRelativeDateDescription = (sign: '+' | '-', value: number, unit: string) => { - if (sign === '-') { - switch (unit) { - case 's': - return tn('Last %s second', 'Last %s seconds', value); - case 'm': - return tn('Last %s minute', 'Last %s minutes', value); - case 'h': - return tn('Last %s hour', 'Last %s hours', value); - case 'd': - return tn('Last %s day', 'Last %s days', value); - case 'w': - return tn('Last %s week', 'Last %s weeks', value); - default: - return ''; - } - } +function getRelativeDateSign(token: TokenResult) { + return token.value.type === Token.VALUE_RELATIVE_DATE ? token.value.sign : '-'; +} +function makeRelativeDateDescription(value: number, unit: string) { switch (unit) { case 's': - return tn('More than %s second ago', 'More than %s seconds ago', value); + return tn('%s second ago', '%s seconds ago', value); case 'm': - return tn('More than %s minute ago', 'More than %s minutes ago', value); + return tn('%s minute ago', '%s minutes ago', value); case 'h': - return tn('More than %s hour ago', 'More than %s hours ago', value); + return tn('%s hour ago', '%s hours ago', value); case 'd': - return tn('More than %s day ago', 'More than %s days ago', value); + return tn('%s day ago', '%s days ago', value); case 'w': - return tn('More than %s week ago', 'More than %s weeks ago', value); + return tn('%s week ago', '%s weeks ago', value); default: return ''; } -}; +} + +function makeDefaultDateSuggestions( + token: TokenResult +): SuggestionSection[] { + const sign = getRelativeDateSign(token); + + return [ + { + sectionText: '', + suggestions: [ + {value: `${sign}1h`, label: makeRelativeDateDescription(1, 'h')}, + {value: `${sign}24h`, label: makeRelativeDateDescription(24, 'h')}, + {value: `${sign}7d`, label: makeRelativeDateDescription(7, 'd')}, + {value: `${sign}14d`, label: makeRelativeDateDescription(14, 'd')}, + {value: `${sign}30d`, label: makeRelativeDateDescription(30, 'd')}, + ], + }, + ]; +} function getNumericSuggestions(inputValue: string): SuggestionSection[] { if (!inputValue) { @@ -181,33 +175,34 @@ function getDurationSuggestions(inputValue: string): SuggestionSection[] { return []; } -function getRelativeDateSuggestions(inputValue: string): SuggestionSection[] { - const match = inputValue.match(RELATIVE_DATE_REGEX); +function getRelativeDateSuggestions( + inputValue: string, + token: TokenResult +): SuggestionSection[] { + const match = inputValue.match(RELATIVE_DATE_INPUT_REGEX); if (!match) { - return DEFAULT_DATE_SUGGESTIONS; + return makeDefaultDateSuggestions(token); } - const [, , value] = match; + const [, value] = match; const intValue = parseInt(value, 10); if (isNaN(intValue)) { - return DEFAULT_DATE_SUGGESTIONS; + return makeDefaultDateSuggestions(token); } + const sign = token.value.type === Token.VALUE_RELATIVE_DATE ? token.value.sign : '-'; + return [ { sectionText: '', - suggestions: [ - ...RELATIVE_DATE_SIGNS.flatMap(sign => - RELATIVE_DATE_UNITS.map(unit => { - return { - value: `${sign}${intValue}${unit}`, - description: makeRelativeDateDescription(sign, intValue, unit), - }; - }) - ), - ], + suggestions: RELATIVE_DATE_UNITS.map(unit => { + return { + value: `${sign}${intValue}${unit}`, + label: makeRelativeDateDescription(intValue, unit), + }; + }), }, ]; } @@ -225,8 +220,10 @@ function getSuggestionDescription(group: SearchGroup | SearchItem) { function getPredefinedValues({ key, inputValue, + token, }: { inputValue: string; + token: TokenResult; key?: Tag; }): SuggestionSection[] { if (!key) { @@ -245,7 +242,7 @@ function getPredefinedValues({ return DEFAULT_BOOLEAN_SUGGESTIONS; // TODO(malwilley): Better date suggestions case FieldValueType.DATE: - return getRelativeDateSuggestions(inputValue); + return getRelativeDateSuggestions(inputValue, token); default: return []; } @@ -351,6 +348,13 @@ function cleanFilterValue(key: string, value: string): string { return value; } return ''; + case FieldValueType.DATE: + const parsed = parseFilterValueDate(value); + + if (!parsed) { + return ''; + } + return value; default: return escapeTagValue(value); } @@ -368,8 +372,8 @@ function useFilterSuggestions({ const {getTagValues, keys} = useSearchQueryBuilder(); const key = keys[token.key.text]; const predefinedValues = useMemo( - () => getPredefinedValues({key, inputValue}), - [key, inputValue] + () => getPredefinedValues({key, inputValue, token}), + [key, inputValue, token] ); const shouldFetchValues = key && !key.predefined && !predefinedValues.length; const canSelectMultipleValues = tokenSupportsMultipleValues(token, keys); @@ -385,7 +389,7 @@ function useFilterSuggestions({ const createItem = useCallback( (suggestion: SuggestionItem, selected = false) => { return { - label: suggestion.value, + label: suggestion.label ?? suggestion.value, value: suggestion.value, details: suggestion.description, textValue: suggestion.value, @@ -538,7 +542,7 @@ export function SearchQueryBuilderValueCombobox({ } else { dispatch({ type: 'UPDATE_TOKEN_VALUE', - token: token.value, + token: token, value: cleanedValue, }); onCommit(); diff --git a/static/app/components/searchSyntax/utils.tsx b/static/app/components/searchSyntax/utils.tsx index 1cfbeeb26383fa..fc49bc61686f9f 100644 --- a/static/app/components/searchSyntax/utils.tsx +++ b/static/app/components/searchSyntax/utils.tsx @@ -290,11 +290,12 @@ export function stringifyToken(token: TokenResult) { return `${token.prefix}[${token.key.value}]`; case Token.VALUE_TEXT: return token.quoted ? `"${token.value}"` : token.value; + case Token.VALUE_RELATIVE_DATE: + return `${token.sign}${token.value}${token.unit}`; case Token.VALUE_BOOLEAN: case Token.VALUE_DURATION: case Token.VALUE_ISO_8601_DATE: case Token.VALUE_PERCENTAGE: - case Token.VALUE_RELATIVE_DATE: case Token.VALUE_SIZE: case Token.VALUE_NUMBER: return token.text;