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 '""';