From 5f133ee91a17a476cef73595b9ea85d5ee485fcd Mon Sep 17 00:00:00 2001 From: Ahmed Mohamed Date: Wed, 8 Oct 2025 15:42:39 -0400 Subject: [PATCH 1/6] create global filter selector component --- .../dashboards/globalFilter/addFilter.tsx | 11 +- .../globalFilter/filterSelector.tsx | 127 ++++++++++++++++++ .../globalFilter/filterSelectorTrigger.tsx | 80 +++++++++++ 3 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 static/app/views/dashboards/globalFilter/filterSelector.tsx create mode 100644 static/app/views/dashboards/globalFilter/filterSelectorTrigger.tsx diff --git a/static/app/views/dashboards/globalFilter/addFilter.tsx b/static/app/views/dashboards/globalFilter/addFilter.tsx index 9e65fc9c421741..22f739b55f44f6 100644 --- a/static/app/views/dashboards/globalFilter/addFilter.tsx +++ b/static/app/views/dashboards/globalFilter/addFilter.tsx @@ -1,5 +1,6 @@ import {useMemo, useState} from 'react'; import styled from '@emotion/styled'; +import pick from 'lodash/pick'; import {Tag as TagBadge} from 'sentry/components/core/badge/tag'; import {Button} from 'sentry/components/core/button'; @@ -26,6 +27,10 @@ export const DATASET_CHOICES = new Map([ const UNSUPPORTED_FIELD_KINDS = [FieldKind.FUNCTION, FieldKind.MEASUREMENT]; +export function getDatasetLabel(dataset: WidgetType) { + return DATASET_CHOICES.get(dataset) ?? ''; +} + function getTagType(tag: Tag, dataset: WidgetType | null) { const fieldType = dataset === WidgetType.SPANS ? 'span' : dataset === WidgetType.LOGS ? 'log' : 'event'; @@ -100,9 +105,11 @@ function AddFilter({onAddFilter}: AddFilterProps) { priority="primary" disabled={!selectedFilterKey} onClick={() => { + if (!selectedFilterKey || !selectedDataset) return; + const newFilter: GlobalFilter = { - dataset: selectedDataset!, - tag: selectedFilterKey!, + dataset: selectedDataset, + tag: pick(selectedFilterKey, 'key', 'name', 'kind'), value: '', }; onAddFilter(newFilter); diff --git a/static/app/views/dashboards/globalFilter/filterSelector.tsx b/static/app/views/dashboards/globalFilter/filterSelector.tsx new file mode 100644 index 00000000000000..b14b559744f188 --- /dev/null +++ b/static/app/views/dashboards/globalFilter/filterSelector.tsx @@ -0,0 +1,127 @@ +import {useMemo, useState} from 'react'; + +import {Button} from 'sentry/components/core/button'; +import {HybridFilter} from 'sentry/components/organizations/hybridFilter'; +import {MutableSearch} from 'sentry/components/searchSyntax/mutableSearch'; +import {t} from 'sentry/locale'; +import {keepPreviousData, useQuery} from 'sentry/utils/queryClient'; +import {useDebouncedValue} from 'sentry/utils/useDebouncedValue'; +import usePageFilters from 'sentry/utils/usePageFilters'; +import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base'; +import {getDatasetLabel} from 'sentry/views/dashboards/globalFilter/addFilter'; +import FilterSelectorTrigger from 'sentry/views/dashboards/globalFilter/filterSelectorTrigger'; +import type {GlobalFilter} from 'sentry/views/dashboards/types'; + +type FilterSelectorProps = { + globalFilter: GlobalFilter; + onRemoveFilter: (filter: GlobalFilter) => void; + onUpdateFilter: (filter: GlobalFilter) => void; +}; + +function FilterSelector({ + globalFilter, + onRemoveFilter, + onUpdateFilter, +}: FilterSelectorProps) { + // Parse global filter condition to retrieve initial state + const initialValues = useMemo(() => { + const mutableSearch = new MutableSearch(globalFilter.value); + return mutableSearch.getFilterValues(globalFilter.tag.key); + }, [globalFilter]); + + const [activeFilterValues, setActiveFilterValues] = useState(initialValues); + + const {dataset, tag} = globalFilter; + const {selection} = usePageFilters(); + const dataProvider = getDatasetConfig(dataset).useSearchBarDataProvider!({ + pageFilters: selection, + }); + + const baseQueryKey = useMemo(() => ['global-dashboard-filters-tag-values', tag], [tag]); + const queryKey = useDebouncedValue(baseQueryKey); + + const queryResult = useQuery({ + // Disable exhaustive deps because we want to debounce the query key above + // eslint-disable-next-line @tanstack/query/exhaustive-deps + queryKey, + queryFn: async () => { + const result = await dataProvider?.getTagValues(tag, ''); + return result ?? []; + }, + placeholderData: keepPreviousData, + enabled: true, + }); + + const {data, isFetching} = queryResult; + const options = useMemo(() => { + if (!data) return []; + return data.map(value => ({ + label: value, + value, + })); + }, [data]); + + const handleChange = (opts: string[]) => { + setActiveFilterValues(opts); + + // Build filter condition string + const filterValue = () => { + if (opts.length === 0) { + return ''; + } + const mutableSearch = new MutableSearch(''); + return mutableSearch.addFilterValueList(tag.key, opts).toString(); + }; + + onUpdateFilter({ + ...globalFilter, + value: filterValue(), + }); + }; + + return ( + { + setActiveFilterValues([]); + onUpdateFilter({ + ...globalFilter, + value: '', + }); + }} + emptyMessage={t('No filter values found')} + menuTitle={t('%s filter', getDatasetLabel(dataset))} + menuHeaderTrailingItems={ + + } + triggerProps={{ + children: ( + + ), + }} + /> + ); +} + +export default FilterSelector; diff --git a/static/app/views/dashboards/globalFilter/filterSelectorTrigger.tsx b/static/app/views/dashboards/globalFilter/filterSelectorTrigger.tsx new file mode 100644 index 00000000000000..df664dd4914cbc --- /dev/null +++ b/static/app/views/dashboards/globalFilter/filterSelectorTrigger.tsx @@ -0,0 +1,80 @@ +import styled from '@emotion/styled'; + +import {Badge} from 'sentry/components/core/badge'; +import type {SelectOption} from 'sentry/components/core/compactSelect/types'; +import LoadingIndicator from 'sentry/components/loadingIndicator'; +import TextOverflow from 'sentry/components/textOverflow'; +import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import {prettifyTagKey} from 'sentry/utils/fields'; +import type {UseQueryResult} from 'sentry/utils/queryClient'; +import type {GlobalFilter} from 'sentry/views/dashboards/types'; + +type FilterSelectorTriggerProps = { + activeFilterValues: string[]; + globalFilter: GlobalFilter; + options: Array>; + queryResult: UseQueryResult; +}; + +function FilterSelectorTrigger({ + globalFilter, + activeFilterValues, + options, + queryResult, +}: FilterSelectorTriggerProps) { + const {isFetching} = queryResult; + const {tag} = globalFilter; + + const shouldShowBadge = + !isFetching && + activeFilterValues.length > 1 && + activeFilterValues.length !== options.length; + const isAllSelected = + activeFilterValues.length === 0 || activeFilterValues.length === options.length; + + return ( + + + {prettifyTagKey(tag.key)}:{' '} + {!isFetching && ( + + {isAllSelected ? t('All') : activeFilterValues[0]} + + )} + + {isFetching && } + {shouldShowBadge && ( + {`+${activeFilterValues.length - 1}`} + )} + + ); +} + +export default FilterSelectorTrigger; + +const StyledLoadingIndicator = styled(LoadingIndicator)` + && { + margin: 0; + margin-left: ${space(0.5)}; + } +`; + +const StyledBadge = styled(Badge)` + flex-shrink: 0; + height: 16px; + line-height: 16px; + min-width: 16px; + border-radius: 16px; + font-size: 10px; + padding: 0 ${space(0.5)}; +`; + +const ButtonLabelWrapper = styled('span')` + width: 100%; + text-align: left; + align-items: center; + display: inline-grid; + grid-template-columns: 1fr auto; + line-height: 1; +`; From 1dd9af8c8ea8ed081def2721a127b82f3ec608b2 Mon Sep 17 00:00:00 2001 From: Ahmed Mohamed Date: Wed, 8 Oct 2025 15:44:48 -0400 Subject: [PATCH 2/6] unit test global filter selector --- .../globalFilter/filterSelector.spec.tsx | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 static/app/views/dashboards/globalFilter/filterSelector.spec.tsx diff --git a/static/app/views/dashboards/globalFilter/filterSelector.spec.tsx b/static/app/views/dashboards/globalFilter/filterSelector.spec.tsx new file mode 100644 index 00000000000000..d3cb560e7a61f6 --- /dev/null +++ b/static/app/views/dashboards/globalFilter/filterSelector.spec.tsx @@ -0,0 +1,130 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {FieldKind} from 'sentry/utils/fields'; +import FilterSelector from 'sentry/views/dashboards/globalFilter/filterSelector'; +import {WidgetType, type GlobalFilter} from 'sentry/views/dashboards/types'; + +describe('FilterSelector', () => { + const mockOnUpdateFilter = jest.fn(); + const mockOnRemoveFilter = jest.fn(); + + const mockGlobalFilter: GlobalFilter = { + dataset: WidgetType.ERRORS, + tag: { + key: 'browser', + name: 'Browser', + kind: FieldKind.FIELD, + }, + value: '', + }; + beforeEach(() => { + MockApiClient.clearMockResponses(); + + // Mock tags endpoint + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/tags/', + body: [], + }); + + // Mock custom measurements endpoint + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/measurements-meta/', + body: {}, + }); + + // Mock tag values endpoint + MockApiClient.addMockResponse({ + url: `/organizations/org-slug/tags/${mockGlobalFilter.tag.key}/values/`, + body: [ + {name: 'chrome', value: 'chrome', count: 100}, + {name: 'firefox', value: 'firefox', count: 50}, + {name: 'safari', value: 'safari', count: 25}, + ], + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders all filter values', async () => { + render( + + ); + + const button = screen.getByRole('button', {name: mockGlobalFilter.tag.key + ':'}); + await userEvent.click(button); + + expect(screen.getByText('chrome')).toBeInTheDocument(); + expect(screen.getByText('firefox')).toBeInTheDocument(); + expect(screen.getByText('safari')).toBeInTheDocument(); + }); + + it('calls onUpdateFilter when options are selected', async () => { + render( + + ); + + const button = screen.getByRole('button', {name: mockGlobalFilter.tag.key + ':'}); + await userEvent.click(button); + + await userEvent.click(screen.getByRole('checkbox', {name: 'Select firefox'})); + await userEvent.click(screen.getByRole('checkbox', {name: 'Select chrome'})); + await userEvent.click(screen.getByRole('button', {name: 'Apply'})); + + expect(mockOnUpdateFilter).toHaveBeenCalledWith({ + ...mockGlobalFilter, + value: 'browser:[firefox,chrome]', + }); + + await userEvent.click(button); + await userEvent.click(screen.getByRole('row', {name: 'chrome'})); + + expect(mockOnUpdateFilter).toHaveBeenCalledWith({ + ...mockGlobalFilter, + value: 'browser:[chrome]', + }); + }); + + it('parses the initial value of the global filter', async () => { + render( + + ); + + const button = screen.getByRole('button', {name: mockGlobalFilter.tag.key + ':'}); + await userEvent.click(button); + + expect(screen.getByRole('checkbox', {name: 'Select firefox'})).toBeChecked(); + expect(screen.getByRole('checkbox', {name: 'Select chrome'})).toBeChecked(); + }); + + it('calls onRemoveFilter when remove button is clicked', async () => { + render( + + ); + + const button = screen.getByRole('button', {name: mockGlobalFilter.tag.key + ':'}); + await userEvent.click(button); + await userEvent.click(screen.getByRole('button', {name: 'Remove Filter'})); + + expect(mockOnRemoveFilter).toHaveBeenCalledWith(mockGlobalFilter); + }); +}); From f5dbaadbb631759ee93b897d9cd2ea1b3d6fb991 Mon Sep 17 00:00:00 2001 From: Ahmed Mohamed Date: Wed, 8 Oct 2025 16:11:46 -0400 Subject: [PATCH 3/6] add empty message placeholder to indicate filter values being fetched --- static/app/views/dashboards/globalFilter/filterSelector.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/static/app/views/dashboards/globalFilter/filterSelector.tsx b/static/app/views/dashboards/globalFilter/filterSelector.tsx index b14b559744f188..072a95237f9aa2 100644 --- a/static/app/views/dashboards/globalFilter/filterSelector.tsx +++ b/static/app/views/dashboards/globalFilter/filterSelector.tsx @@ -97,7 +97,9 @@ function FilterSelector({ value: '', }); }} - emptyMessage={t('No filter values found')} + emptyMessage={ + isFetching ? t('Loading filter values...') : t('No filter values found') + } menuTitle={t('%s filter', getDatasetLabel(dataset))} menuHeaderTrailingItems={