Skip to content

Commit 3cbaeb2

Browse files
authored
feat(dashboards): Add global filter selector component (#101196)
- Creates a global filter selector component for managing global filters - This component allows selecting and updating filter values - Uses mutable search to create a filter query string that can be appended to widget queries. - Sets up placeholder callbacks for persisting changes later on. - Note: Logic for persisting global filter changes will be in a separate PR Fixes [DAIN-929](https://linear.app/getsentry/issue/DAIN-929/create-dashboard-global-filter-selector-component)
1 parent 73bbb6f commit 3cbaeb2

File tree

6 files changed

+361
-3
lines changed

6 files changed

+361
-3
lines changed

static/app/views/dashboards/filtersBar.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {useUserTeams} from 'sentry/utils/useUserTeams';
2323
import AddFilter from 'sentry/views/dashboards/globalFilter/addFilter';
2424
import {useInvalidateStarredDashboards} from 'sentry/views/dashboards/hooks/useInvalidateStarredDashboards';
2525

26+
import FilterSelector from './globalFilter/filterSelector';
2627
import {checkUserHasEditAccess} from './utils/checkUserHasEditAccess';
2728
import ReleasesSelectControl from './releasesSelectControl';
2829
import type {DashboardFilters, DashboardPermissions} from './types';
@@ -122,7 +123,17 @@ export default function FiltersBar({
122123
</ReleasesProvider>
123124

124125
{organization.features.includes('dashboards-global-filters') && (
125-
<AddFilter onAddFilter={() => {}} />
126+
<Fragment>
127+
{filters[DashboardFilterKeys.GLOBAL_FILTER]?.map(globalFilter => (
128+
<FilterSelector
129+
key={globalFilter.tag.key}
130+
globalFilter={globalFilter}
131+
onUpdateFilter={() => {}}
132+
onRemoveFilter={() => {}}
133+
/>
134+
))}
135+
<AddFilter onAddFilter={() => {}} />
136+
</Fragment>
126137
)}
127138
</FilterButtons>
128139
{hasUnsavedChanges && !isEditingDashboard && !isPreview && (

static/app/views/dashboards/globalFilter/addFilter.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {useMemo, useState} from 'react';
22
import styled from '@emotion/styled';
3+
import pick from 'lodash/pick';
34

45
import {Tag as TagBadge} from 'sentry/components/core/badge/tag';
56
import {Button} from 'sentry/components/core/button';
@@ -26,6 +27,10 @@ export const DATASET_CHOICES = new Map<WidgetType, string>([
2627

2728
const UNSUPPORTED_FIELD_KINDS = [FieldKind.FUNCTION, FieldKind.MEASUREMENT];
2829

30+
export function getDatasetLabel(dataset: WidgetType) {
31+
return DATASET_CHOICES.get(dataset) ?? '';
32+
}
33+
2934
function getTagType(tag: Tag, dataset: WidgetType | null) {
3035
const fieldType =
3136
dataset === WidgetType.SPANS ? 'span' : dataset === WidgetType.LOGS ? 'log' : 'event';
@@ -100,9 +105,11 @@ function AddFilter({onAddFilter}: AddFilterProps) {
100105
priority="primary"
101106
disabled={!selectedFilterKey}
102107
onClick={() => {
108+
if (!selectedFilterKey || !selectedDataset) return;
109+
103110
const newFilter: GlobalFilter = {
104-
dataset: selectedDataset!,
105-
tag: selectedFilterKey!,
111+
dataset: selectedDataset,
112+
tag: pick(selectedFilterKey, 'key', 'name', 'kind'),
106113
value: '',
107114
};
108115
onAddFilter(newFilter);
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
2+
3+
import {FieldKind} from 'sentry/utils/fields';
4+
import FilterSelector from 'sentry/views/dashboards/globalFilter/filterSelector';
5+
import {WidgetType, type GlobalFilter} from 'sentry/views/dashboards/types';
6+
7+
describe('FilterSelector', () => {
8+
const mockOnUpdateFilter = jest.fn();
9+
const mockOnRemoveFilter = jest.fn();
10+
11+
const mockGlobalFilter: GlobalFilter = {
12+
dataset: WidgetType.ERRORS,
13+
tag: {
14+
key: 'browser',
15+
name: 'Browser',
16+
kind: FieldKind.FIELD,
17+
},
18+
value: '',
19+
};
20+
beforeEach(() => {
21+
MockApiClient.clearMockResponses();
22+
23+
// Mock tags endpoint
24+
MockApiClient.addMockResponse({
25+
url: '/organizations/org-slug/tags/',
26+
body: [],
27+
});
28+
29+
// Mock custom measurements endpoint
30+
MockApiClient.addMockResponse({
31+
url: '/organizations/org-slug/measurements-meta/',
32+
body: {},
33+
});
34+
35+
// Mock tag values endpoint
36+
MockApiClient.addMockResponse({
37+
url: `/organizations/org-slug/tags/${mockGlobalFilter.tag.key}/values/`,
38+
body: [
39+
{name: 'chrome', value: 'chrome', count: 100},
40+
{name: 'firefox', value: 'firefox', count: 50},
41+
{name: 'safari', value: 'safari', count: 25},
42+
],
43+
});
44+
});
45+
46+
afterEach(() => {
47+
jest.clearAllMocks();
48+
});
49+
50+
it('renders all filter values', async () => {
51+
render(
52+
<FilterSelector
53+
globalFilter={mockGlobalFilter}
54+
onUpdateFilter={mockOnUpdateFilter}
55+
onRemoveFilter={mockOnRemoveFilter}
56+
/>
57+
);
58+
59+
const button = screen.getByRole('button', {name: mockGlobalFilter.tag.key + ':'});
60+
await userEvent.click(button);
61+
62+
expect(screen.getByText('chrome')).toBeInTheDocument();
63+
expect(screen.getByText('firefox')).toBeInTheDocument();
64+
expect(screen.getByText('safari')).toBeInTheDocument();
65+
});
66+
67+
it('calls onUpdateFilter when options are selected', async () => {
68+
render(
69+
<FilterSelector
70+
globalFilter={mockGlobalFilter}
71+
onUpdateFilter={mockOnUpdateFilter}
72+
onRemoveFilter={mockOnRemoveFilter}
73+
/>
74+
);
75+
76+
const button = screen.getByRole('button', {name: mockGlobalFilter.tag.key + ':'});
77+
await userEvent.click(button);
78+
79+
await userEvent.click(screen.getByRole('checkbox', {name: 'Select firefox'}));
80+
await userEvent.click(screen.getByRole('checkbox', {name: 'Select chrome'}));
81+
await userEvent.click(screen.getByRole('button', {name: 'Apply'}));
82+
83+
expect(mockOnUpdateFilter).toHaveBeenCalledWith({
84+
...mockGlobalFilter,
85+
value: 'browser:[firefox,chrome]',
86+
});
87+
88+
await userEvent.click(button);
89+
await userEvent.click(screen.getByRole('row', {name: 'chrome'}));
90+
91+
expect(mockOnUpdateFilter).toHaveBeenCalledWith({
92+
...mockGlobalFilter,
93+
value: 'browser:[chrome]',
94+
});
95+
});
96+
97+
it('parses the initial value of the global filter', async () => {
98+
render(
99+
<FilterSelector
100+
globalFilter={{...mockGlobalFilter, value: 'browser:[firefox,chrome]'}}
101+
onUpdateFilter={mockOnUpdateFilter}
102+
onRemoveFilter={mockOnRemoveFilter}
103+
/>
104+
);
105+
106+
const button = screen.getByRole('button', {name: mockGlobalFilter.tag.key + ':'});
107+
await userEvent.click(button);
108+
109+
expect(screen.getByRole('checkbox', {name: 'Select firefox'})).toBeChecked();
110+
expect(screen.getByRole('checkbox', {name: 'Select chrome'})).toBeChecked();
111+
});
112+
113+
it('calls onRemoveFilter when remove button is clicked', async () => {
114+
render(
115+
<FilterSelector
116+
globalFilter={mockGlobalFilter}
117+
onUpdateFilter={mockOnUpdateFilter}
118+
onRemoveFilter={mockOnRemoveFilter}
119+
/>
120+
);
121+
122+
const button = screen.getByRole('button', {name: mockGlobalFilter.tag.key + ':'});
123+
await userEvent.click(button);
124+
await userEvent.click(screen.getByRole('button', {name: 'Remove Filter'}));
125+
126+
expect(mockOnRemoveFilter).toHaveBeenCalledWith(mockGlobalFilter);
127+
});
128+
});
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import {useMemo, useState} from 'react';
2+
3+
import {Button} from 'sentry/components/core/button';
4+
import {HybridFilter} from 'sentry/components/organizations/hybridFilter';
5+
import {MutableSearch} from 'sentry/components/searchSyntax/mutableSearch';
6+
import {t} from 'sentry/locale';
7+
import {keepPreviousData, useQuery} from 'sentry/utils/queryClient';
8+
import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
9+
import usePageFilters from 'sentry/utils/usePageFilters';
10+
import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base';
11+
import {getDatasetLabel} from 'sentry/views/dashboards/globalFilter/addFilter';
12+
import FilterSelectorTrigger from 'sentry/views/dashboards/globalFilter/filterSelectorTrigger';
13+
import type {GlobalFilter} from 'sentry/views/dashboards/types';
14+
15+
type FilterSelectorProps = {
16+
globalFilter: GlobalFilter;
17+
onRemoveFilter: (filter: GlobalFilter) => void;
18+
onUpdateFilter: (filter: GlobalFilter) => void;
19+
};
20+
21+
function FilterSelector({
22+
globalFilter,
23+
onRemoveFilter,
24+
onUpdateFilter,
25+
}: FilterSelectorProps) {
26+
// Parse global filter condition to retrieve initial state
27+
const initialValues = useMemo(() => {
28+
const mutableSearch = new MutableSearch(globalFilter.value);
29+
return mutableSearch.getFilterValues(globalFilter.tag.key);
30+
}, [globalFilter]);
31+
32+
const [activeFilterValues, setActiveFilterValues] = useState<string[]>(initialValues);
33+
34+
const {dataset, tag} = globalFilter;
35+
const {selection} = usePageFilters();
36+
const dataProvider = getDatasetConfig(dataset).useSearchBarDataProvider!({
37+
pageFilters: selection,
38+
});
39+
40+
const baseQueryKey = useMemo(() => ['global-dashboard-filters-tag-values', tag], [tag]);
41+
const queryKey = useDebouncedValue(baseQueryKey);
42+
43+
const queryResult = useQuery<string[]>({
44+
// Disable exhaustive deps because we want to debounce the query key above
45+
// eslint-disable-next-line @tanstack/query/exhaustive-deps
46+
queryKey,
47+
queryFn: async () => {
48+
const result = await dataProvider?.getTagValues(tag, '');
49+
return result ?? [];
50+
},
51+
placeholderData: keepPreviousData,
52+
enabled: true,
53+
});
54+
55+
const {data, isFetching} = queryResult;
56+
const options = useMemo(() => {
57+
if (!data) return [];
58+
return data.map(value => ({
59+
label: value,
60+
value,
61+
}));
62+
}, [data]);
63+
64+
const handleChange = (opts: string[]) => {
65+
setActiveFilterValues(opts);
66+
67+
// Build filter condition string
68+
const filterValue = () => {
69+
if (opts.length === 0) {
70+
return '';
71+
}
72+
const mutableSearch = new MutableSearch('');
73+
return mutableSearch.addFilterValueList(tag.key, opts).toString();
74+
};
75+
76+
onUpdateFilter({
77+
...globalFilter,
78+
value: filterValue(),
79+
});
80+
};
81+
82+
return (
83+
<HybridFilter
84+
checkboxPosition="leading"
85+
searchable
86+
disabled={false}
87+
options={options}
88+
value={activeFilterValues}
89+
defaultValue={[]}
90+
onChange={handleChange}
91+
sizeLimit={10}
92+
sizeLimitMessage={t('Use search to find more filter values…')}
93+
onReset={() => {
94+
setActiveFilterValues([]);
95+
onUpdateFilter({
96+
...globalFilter,
97+
value: '',
98+
});
99+
}}
100+
emptyMessage={
101+
isFetching ? t('Loading filter values...') : t('No filter values found')
102+
}
103+
menuTitle={t('%s filter', getDatasetLabel(dataset))}
104+
menuHeaderTrailingItems={
105+
<Button
106+
aria-label={t('Remove Filter')}
107+
borderless
108+
size="xs"
109+
priority="link"
110+
onClick={() => onRemoveFilter(globalFilter)}
111+
>
112+
{t('Remove')}
113+
</Button>
114+
}
115+
triggerProps={{
116+
children: (
117+
<FilterSelectorTrigger
118+
globalFilter={globalFilter}
119+
activeFilterValues={activeFilterValues}
120+
options={options}
121+
queryResult={queryResult}
122+
/>
123+
),
124+
}}
125+
/>
126+
);
127+
}
128+
129+
export default FilterSelector;
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import styled from '@emotion/styled';
2+
3+
import {Badge} from 'sentry/components/core/badge';
4+
import type {SelectOption} from 'sentry/components/core/compactSelect/types';
5+
import LoadingIndicator from 'sentry/components/loadingIndicator';
6+
import TextOverflow from 'sentry/components/textOverflow';
7+
import {t} from 'sentry/locale';
8+
import {space} from 'sentry/styles/space';
9+
import {prettifyTagKey} from 'sentry/utils/fields';
10+
import type {UseQueryResult} from 'sentry/utils/queryClient';
11+
import type {GlobalFilter} from 'sentry/views/dashboards/types';
12+
13+
type FilterSelectorTriggerProps = {
14+
activeFilterValues: string[];
15+
globalFilter: GlobalFilter;
16+
options: Array<SelectOption<string>>;
17+
queryResult: UseQueryResult<string[], Error>;
18+
};
19+
20+
function FilterSelectorTrigger({
21+
globalFilter,
22+
activeFilterValues,
23+
options,
24+
queryResult,
25+
}: FilterSelectorTriggerProps) {
26+
const {isFetching} = queryResult;
27+
const {tag} = globalFilter;
28+
29+
const shouldShowBadge =
30+
!isFetching &&
31+
activeFilterValues.length > 1 &&
32+
activeFilterValues.length !== options.length;
33+
const isAllSelected =
34+
activeFilterValues.length === 0 || activeFilterValues.length === options.length;
35+
36+
return (
37+
<ButtonLabelWrapper>
38+
<TextOverflow>
39+
{prettifyTagKey(tag.key)}:{' '}
40+
{!isFetching && (
41+
<span style={{fontWeight: 'normal'}}>
42+
{isAllSelected ? t('All') : activeFilterValues[0]}
43+
</span>
44+
)}
45+
</TextOverflow>
46+
{isFetching && <StyledLoadingIndicator size={14} />}
47+
{shouldShowBadge && (
48+
<StyledBadge type="default">{`+${activeFilterValues.length - 1}`}</StyledBadge>
49+
)}
50+
</ButtonLabelWrapper>
51+
);
52+
}
53+
54+
export default FilterSelectorTrigger;
55+
56+
const StyledLoadingIndicator = styled(LoadingIndicator)`
57+
&& {
58+
margin: 0;
59+
margin-left: ${space(0.5)};
60+
}
61+
`;
62+
63+
const StyledBadge = styled(Badge)`
64+
flex-shrink: 0;
65+
height: 16px;
66+
line-height: 16px;
67+
min-width: 16px;
68+
border-radius: 16px;
69+
font-size: 10px;
70+
padding: 0 ${space(0.5)};
71+
`;
72+
73+
const ButtonLabelWrapper = styled('span')`
74+
width: 100%;
75+
text-align: left;
76+
align-items: center;
77+
display: inline-grid;
78+
grid-template-columns: 1fr auto;
79+
line-height: 1;
80+
`;

0 commit comments

Comments
 (0)