Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion static/app/views/dashboards/filtersBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {useUserTeams} from 'sentry/utils/useUserTeams';
import AddFilter from 'sentry/views/dashboards/globalFilter/addFilter';
import {useInvalidateStarredDashboards} from 'sentry/views/dashboards/hooks/useInvalidateStarredDashboards';

import FilterSelector from './globalFilter/filterSelector';
import {checkUserHasEditAccess} from './utils/checkUserHasEditAccess';
import ReleasesSelectControl from './releasesSelectControl';
import type {DashboardFilters, DashboardPermissions} from './types';
Expand Down Expand Up @@ -122,7 +123,17 @@ export default function FiltersBar({
</ReleasesProvider>

{organization.features.includes('dashboards-global-filters') && (
<AddFilter onAddFilter={() => {}} />
<Fragment>
{filters[DashboardFilterKeys.GLOBAL_FILTER]?.map(globalFilter => (
<FilterSelector
key={globalFilter.tag.key}
globalFilter={globalFilter}
onUpdateFilter={() => {}}
onRemoveFilter={() => {}}
/>
))}
<AddFilter onAddFilter={() => {}} />
</Fragment>
)}
</FilterButtons>
{hasUnsavedChanges && !isEditingDashboard && !isPreview && (
Expand Down
11 changes: 9 additions & 2 deletions static/app/views/dashboards/globalFilter/addFilter.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -26,6 +27,10 @@ export const DATASET_CHOICES = new Map<WidgetType, string>([

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';
Expand Down Expand Up @@ -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);
Expand Down
128 changes: 128 additions & 0 deletions static/app/views/dashboards/globalFilter/filterSelector.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import {render, screen, userEvent} 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(
<FilterSelector
globalFilter={mockGlobalFilter}
onUpdateFilter={mockOnUpdateFilter}
onRemoveFilter={mockOnRemoveFilter}
/>
);

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(
<FilterSelector
globalFilter={mockGlobalFilter}
onUpdateFilter={mockOnUpdateFilter}
onRemoveFilter={mockOnRemoveFilter}
/>
);

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(
<FilterSelector
globalFilter={{...mockGlobalFilter, value: 'browser:[firefox,chrome]'}}
onUpdateFilter={mockOnUpdateFilter}
onRemoveFilter={mockOnRemoveFilter}
/>
);

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(
<FilterSelector
globalFilter={mockGlobalFilter}
onUpdateFilter={mockOnUpdateFilter}
onRemoveFilter={mockOnRemoveFilter}
/>
);

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);
});
});
129 changes: 129 additions & 0 deletions static/app/views/dashboards/globalFilter/filterSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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<string[]>(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<string[]>({
// 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 (
<HybridFilter
checkboxPosition="leading"
searchable
disabled={false}
options={options}
value={activeFilterValues}
defaultValue={[]}
onChange={handleChange}
sizeLimit={10}
sizeLimitMessage={t('Use search to find more filter values…')}
onReset={() => {
setActiveFilterValues([]);
onUpdateFilter({
...globalFilter,
value: '',
});
}}
emptyMessage={
isFetching ? t('Loading filter values...') : t('No filter values found')
}
menuTitle={t('%s filter', getDatasetLabel(dataset))}
menuHeaderTrailingItems={
<Button
aria-label={t('Remove Filter')}
borderless
size="xs"
priority="link"
onClick={() => onRemoveFilter(globalFilter)}
>
{t('Remove')}
</Button>
}
triggerProps={{
children: (
<FilterSelectorTrigger
globalFilter={globalFilter}
activeFilterValues={activeFilterValues}
options={options}
queryResult={queryResult}
/>
),
}}
/>
);
}

export default FilterSelector;
Original file line number Diff line number Diff line change
@@ -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<SelectOption<string>>;
queryResult: UseQueryResult<string[], Error>;
};

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 (
<ButtonLabelWrapper>
<TextOverflow>
{prettifyTagKey(tag.key)}:{' '}
{!isFetching && (
<span style={{fontWeight: 'normal'}}>
{isAllSelected ? t('All') : activeFilterValues[0]}
</span>
)}
</TextOverflow>
{isFetching && <StyledLoadingIndicator size={14} />}
{shouldShowBadge && (
<StyledBadge type="default">{`+${activeFilterValues.length - 1}`}</StyledBadge>
)}
</ButtonLabelWrapper>
);
}

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;
`;
Loading
Loading