diff --git a/static/app/components/searchQueryBuilder/index.spec.tsx b/static/app/components/searchQueryBuilder/index.spec.tsx index 2a2183f0e6c9c..4df274518c7be 100644 --- a/static/app/components/searchQueryBuilder/index.spec.tsx +++ b/static/app/components/searchQueryBuilder/index.spec.tsx @@ -8,14 +8,18 @@ import { within, } from 'sentry-test/reactTestingLibrary'; -import {SearchQueryBuilder} from 'sentry/components/searchQueryBuilder'; import { + SearchQueryBuilder, + type SearchQueryBuilderProps, +} from 'sentry/components/searchQueryBuilder'; +import { + type FieldDefinitionGetter, type FilterKeySection, QueryInterfaceType, } from 'sentry/components/searchQueryBuilder/types'; import {INTERFACE_TYPE_LOCALSTORAGE_KEY} from 'sentry/components/searchQueryBuilder/utils'; import type {TagCollection} from 'sentry/types/group'; -import {FieldKey, FieldKind} from 'sentry/utils/fields'; +import {FieldKey, FieldKind, FieldValueType} from 'sentry/utils/fields'; import localStorageWrapper from 'sentry/utils/localStorage'; const FILTER_KEYS: TagCollection = { @@ -1340,6 +1344,145 @@ describe('SearchQueryBuilder', function () { }); }); + describe('duration', function () { + const durationFilterKeys: TagCollection = { + duration: { + key: 'duration', + name: 'Duration', + }, + }; + + const fieldDefinitionGetter: FieldDefinitionGetter = () => ({ + valueType: FieldValueType.DURATION, + kind: FieldKind.FIELD, + }); + + const durationProps: SearchQueryBuilderProps = { + ...defaultProps, + filterKeys: durationFilterKeys, + filterKeySections: [], + fieldDefinitionGetter, + }; + + it('new duration filters start with greater than operator and default value', async function () { + render(); + await userEvent.click(getLastInput()); + await userEvent.click(screen.getByRole('option', {name: 'duration'})); + + // Should start with the > operator and a value of 10ms + expect( + await screen.findByRole('row', {name: 'duration:>10ms'}) + ).toBeInTheDocument(); + }); + + it('duration filters have the correct operator options', async function () { + render(); + await userEvent.click( + screen.getByRole('button', {name: 'Edit operator for filter: duration'}) + ); + + expect( + await screen.findByRole('option', {name: 'duration is'}) + ).toBeInTheDocument(); + expect(screen.getByRole('option', {name: 'duration is not'})).toBeInTheDocument(); + expect(screen.getByRole('option', {name: 'duration >'})).toBeInTheDocument(); + expect(screen.getByRole('option', {name: 'duration <'})).toBeInTheDocument(); + expect(screen.getByRole('option', {name: 'duration >='})).toBeInTheDocument(); + expect(screen.getByRole('option', {name: 'duration <='})).toBeInTheDocument(); + }); + + it('duration filters have the correct value suggestions', async function () { + render(); + await userEvent.click( + screen.getByRole('button', {name: 'Edit value for filter: duration'}) + ); + + // Default suggestions + expect(await screen.findByRole('option', {name: '100ms'})).toBeInTheDocument(); + expect(screen.getByRole('option', {name: '100s'})).toBeInTheDocument(); + expect(screen.getByRole('option', {name: '100m'})).toBeInTheDocument(); + expect(screen.getByRole('option', {name: '100h'})).toBeInTheDocument(); + + // Entering a number will show unit suggestions for that value + await userEvent.keyboard('7'); + expect(await screen.findByRole('option', {name: '7ms'})).toBeInTheDocument(); + expect(screen.getByRole('option', {name: '7s'})).toBeInTheDocument(); + expect(screen.getByRole('option', {name: '7m'})).toBeInTheDocument(); + expect(screen.getByRole('option', {name: '7h'})).toBeInTheDocument(); + }); + + it('duration filters can change operator', async function () { + render(); + await userEvent.click( + screen.getByRole('button', {name: 'Edit operator for filter: duration'}) + ); + + await userEvent.click(await screen.findByRole('option', {name: 'duration <='})); + + expect( + await screen.findByRole('row', {name: 'duration:<=100ms'}) + ).toBeInTheDocument(); + }); + + it('duration filters do not allow invalid values', async function () { + render(); + await userEvent.click( + screen.getByRole('button', {name: 'Edit value for filter: duration'}) + ); + + await userEvent.keyboard('a{Enter}'); + + // Should have the same value because "a" is not a numeric value + expect(screen.getByRole('row', {name: 'duration:>100ms'})).toBeInTheDocument(); + + await userEvent.keyboard('{Backspace}7m{Enter}'); + + // Should accept "7m" as a valid value + expect( + await screen.findByRole('row', {name: 'duration:>7m'}) + ).toBeInTheDocument(); + }); + + it('duration filters will add a default unit to entered numbers', async function () { + render(); + await userEvent.click( + screen.getByRole('button', {name: 'Edit value for filter: duration'}) + ); + + await userEvent.keyboard('7{Enter}'); + + // Should accept "7" and add "ms" as the default unit + expect( + await screen.findByRole('row', {name: 'duration:>7ms'}) + ).toBeInTheDocument(); + }); + + it('keeps previous value when confirming empty value', async function () { + const mockOnChange = jest.fn(); + render( + + ); + + await userEvent.click( + screen.getByRole('button', {name: 'Edit value for filter: duration'}) + ); + await userEvent.clear( + await screen.findByRole('combobox', {name: 'Edit filter value'}) + ); + await userEvent.keyboard('{enter}'); + + // Should have the same value + expect( + await screen.findByRole('row', {name: 'duration:>100ms'}) + ).toBeInTheDocument(); + expect(mockOnChange).not.toHaveBeenCalled(); + }); + }); + describe('date', function () { // Transpile the lazy-loaded datepicker up front so tests don't flake beforeAll(async function () { diff --git a/static/app/components/searchQueryBuilder/index.stories.tsx b/static/app/components/searchQueryBuilder/index.stories.tsx index ff975b8247ba0..0be926c2a1439 100644 --- a/static/app/components/searchQueryBuilder/index.stories.tsx +++ b/static/app/components/searchQueryBuilder/index.stories.tsx @@ -7,7 +7,7 @@ import type {FilterKeySection} from 'sentry/components/searchQueryBuilder/types' import SizingWindow from 'sentry/components/stories/sizingWindow'; import storyBook from 'sentry/stories/storyBook'; import type {TagCollection} from 'sentry/types/group'; -import {FieldKey, FieldKind} from 'sentry/utils/fields'; +import {FieldKey, FieldKind, WebVital} from 'sentry/utils/fields'; const FILTER_KEYS: TagCollection = { [FieldKey.AGE]: {key: FieldKey.AGE, name: 'Age', kind: FieldKind.FIELD}, @@ -49,6 +49,11 @@ const FILTER_KEYS: TagCollection = { name: 'timesSeen', kind: FieldKind.FIELD, }, + [WebVital.LCP]: { + key: WebVital.LCP, + name: 'lcp', + kind: FieldKind.FIELD, + }, custom_tag_name: { key: 'custom_tag_name', name: 'Custom_Tag_Name', @@ -65,6 +70,7 @@ const FITLER_KEY_SECTIONS: FilterKeySection[] = [ FieldKey.BROWSER_NAME, FieldKey.IS, FieldKey.TIMES_SEEN, + WebVital.LCP, ], }, { diff --git a/static/app/components/searchQueryBuilder/index.tsx b/static/app/components/searchQueryBuilder/index.tsx index 1fc13fb32bd87..7083b9acf7412 100644 --- a/static/app/components/searchQueryBuilder/index.tsx +++ b/static/app/components/searchQueryBuilder/index.tsx @@ -26,7 +26,7 @@ import PanelProvider from 'sentry/utils/panelProvider'; import {useDimensions} from 'sentry/utils/useDimensions'; import {useEffectAfterFirstRender} from 'sentry/utils/useEffectAfterFirstRender'; -interface SearchQueryBuilderProps { +export interface SearchQueryBuilderProps { /** * A complete mapping of all possible filter keys. * Filter keys not included will not show up when typing and may be shown as invalid. diff --git a/static/app/components/searchQueryBuilder/tokens/filter/parsers/duration/grammar.pegjs b/static/app/components/searchQueryBuilder/tokens/filter/parsers/duration/grammar.pegjs new file mode 100644 index 0000000000000..5771bec873ef2 --- /dev/null +++ b/static/app/components/searchQueryBuilder/tokens/filter/parsers/duration/grammar.pegjs @@ -0,0 +1,10 @@ +value = duration_format + +duration_format + = value:numeric + unit:duration_unit? { + return {value, unit} + } + +duration_unit = "ms"/"s"/"min"/"m"/"hr"/"h"/"day"/"d"/"wk"/"w" +numeric = [0-9]+ ("." [0-9]*)? { return text(); } diff --git a/static/app/components/searchQueryBuilder/tokens/filter/parsers/duration/parser.tsx b/static/app/components/searchQueryBuilder/tokens/filter/parsers/duration/parser.tsx new file mode 100644 index 0000000000000..7331d8e979cf8 --- /dev/null +++ b/static/app/components/searchQueryBuilder/tokens/filter/parsers/duration/parser.tsx @@ -0,0 +1,21 @@ +import grammar from './grammar.pegjs'; + +type DurationTokenValue = { + value: string; + unit?: string; +}; + +/** + * This parser is specifically meant for parsing the value of a duartion filter. + * This should mostly mirror the grammar used for search syntax, but is a little + * more lenient. This parser still returns a valid result if the duration does + * not contain a unit which can be used to help create a valid duration or show + * search suggestions. + */ +export function parseFilterValueDuration(query: string): DurationTokenValue | null { + try { + return grammar.parse(query); + } catch (e) { + return null; + } +} diff --git a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx index c10d3a8784e86..af2d091a18d79 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx @@ -9,6 +9,7 @@ import {getItemsWithKeys} from 'sentry/components/compactSelect/utils'; import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context'; import {SearchQueryBuilderCombobox} from 'sentry/components/searchQueryBuilder/tokens/combobox'; import {parseFilterValueDate} from 'sentry/components/searchQueryBuilder/tokens/filter/parsers/date/parser'; +import {parseFilterValueDuration} from 'sentry/components/searchQueryBuilder/tokens/filter/parsers/duration/parser'; import SpecificDatePicker from 'sentry/components/searchQueryBuilder/tokens/filter/specificDatePicker'; import { escapeTagValue, @@ -81,7 +82,7 @@ function isStringFilterValues( const NUMERIC_UNITS = ['k', 'm', 'b'] as const; const RELATIVE_DATE_UNITS = ['m', 'h', 'd', 'w'] as const; -const DURATION_UNITS = ['ms', 's', 'm', 'h', 'd', 'w'] as const; +const DURATION_UNIT_SUGGESTIONS = ['ms', 's', 'm', 'h'] as const; const DEFAULT_NUMERIC_SUGGESTIONS: SuggestionSection[] = [ { @@ -93,7 +94,7 @@ const DEFAULT_NUMERIC_SUGGESTIONS: SuggestionSection[] = [ const DEFAULT_DURATION_SUGGESTIONS: SuggestionSection[] = [ { sectionText: '', - suggestions: [{value: '100'}, {value: '100k'}, {value: '100m'}, {value: '100b'}], + suggestions: DURATION_UNIT_SUGGESTIONS.map(unit => ({value: `10${unit}`})), }, ]; @@ -276,23 +277,42 @@ function getNumericSuggestions(inputValue: string): SuggestionSection[] { return []; } -function getDurationSuggestions(inputValue: string): SuggestionSection[] { +function getDurationSuggestions( + inputValue: string, + token: TokenResult +): SuggestionSection[] { if (!inputValue) { - return DEFAULT_DURATION_SUGGESTIONS; + const currentValue = + token.value.type === Token.VALUE_DURATION ? token.value.value : null; + + if (!currentValue) { + return DEFAULT_DURATION_SUGGESTIONS; + } + + return [ + { + sectionText: '', + suggestions: DURATION_UNIT_SUGGESTIONS.map(unit => ({ + value: `${currentValue}${unit}`, + })), + }, + ]; } - if (isNumeric(inputValue)) { + const parsed = parseFilterValueDuration(inputValue); + + if (parsed) { return [ { sectionText: '', - suggestions: DURATION_UNITS.map(unit => ({ - value: `${inputValue}${unit}`, + suggestions: DURATION_UNIT_SUGGESTIONS.map(unit => ({ + value: `${parsed.value}${unit}`, })), }, ]; } - // If the value is not numeric, don't show any suggestions + // If the value doesn't contain any valid number or duration, don't show any suggestions return []; } @@ -348,9 +368,9 @@ function getPredefinedValues({ filterValue: string; token: TokenResult; key?: Tag; -}): SuggestionSection[] { +}): SuggestionSection[] | null { if (!key) { - return []; + return null; } if (!key.values?.length) { @@ -358,14 +378,13 @@ function getPredefinedValues({ case FieldValueType.NUMBER: return getNumericSuggestions(filterValue); case FieldValueType.DURATION: - return getDurationSuggestions(filterValue); + return getDurationSuggestions(filterValue, token); case FieldValueType.BOOLEAN: return DEFAULT_BOOLEAN_SUGGESTIONS; - // TODO(malwilley): Better date suggestions case FieldValueType.DATE: return getRelativeDateSuggestions(filterValue, token); default: - return []; + return null; } } @@ -450,6 +469,17 @@ function cleanFilterValue( return value; } return null; + case FieldValueType.DURATION: { + const parsed = parseFilterValueDuration(value); + if (!parsed) { + return null; + } + // Default to ms if no unit is provided + if (!parsed.unit) { + return `${parsed.value}ms`; + } + return value; + } case FieldValueType.DATE: const parsed = parseFilterValueDate(value); @@ -517,7 +547,7 @@ function useFilterSuggestions({ }), [key, filterValue, token, fieldDefinition] ); - const shouldFetchValues = key && !key.predefined && !predefinedValues.length; + const shouldFetchValues = key && !key.predefined && predefinedValues === null; const canSelectMultipleValues = tokenSupportsMultipleValues( token, filterKeys, @@ -572,7 +602,7 @@ function useFilterSuggestions({ const suggestionGroups: SuggestionSection[] = useMemo(() => { return shouldFetchValues ? [{sectionText: '', suggestions: data?.map(value => ({value})) ?? []}] - : predefinedValues; + : predefinedValues ?? []; }, [data, predefinedValues, shouldFetchValues]); // Grouped sections for rendering purposes diff --git a/static/app/components/searchQueryBuilder/tokens/freeText.tsx b/static/app/components/searchQueryBuilder/tokens/freeText.tsx index 4f93422b99ce7..2279eaf9a4a38 100644 --- a/static/app/components/searchQueryBuilder/tokens/freeText.tsx +++ b/static/app/components/searchQueryBuilder/tokens/freeText.tsx @@ -98,6 +98,7 @@ function getInitialFilterText(key: string, fieldDefinition: FieldDefinition | nu switch (fieldDefinition?.valueType) { case FieldValueType.INTEGER: case FieldValueType.NUMBER: + case FieldValueType.DURATION: return `${key}:>${defaultValue}`; case FieldValueType.STRING: default: diff --git a/static/app/components/searchQueryBuilder/tokens/utils.tsx b/static/app/components/searchQueryBuilder/tokens/utils.tsx index 42ce327648a15..32fa84d9e3d41 100644 --- a/static/app/components/searchQueryBuilder/tokens/utils.tsx +++ b/static/app/components/searchQueryBuilder/tokens/utils.tsx @@ -61,6 +61,8 @@ export function getDefaultFilterValue({ return '100'; case FieldValueType.DATE: return '-24h'; + case FieldValueType.DURATION: + return '10ms'; case FieldValueType.STRING: default: return '""';