From 1befac4260f40f1ef5581b7aaad5565961dc6a9a Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 5 Dec 2025 16:31:09 -0500 Subject: [PATCH 1/9] handle temporary filters when changing values --- static/app/views/dashboards/detail.tsx | 8 ++++++++ static/app/views/dashboards/filtersBar.tsx | 6 +++++- .../dashboards/globalFilter/filterSelector.tsx | 18 +++++++++++------- .../globalFilter/genericFilterSelector.tsx | 1 + static/app/views/dashboards/types.tsx | 1 + static/app/views/dashboards/utils.tsx | 6 ++++-- 6 files changed, 30 insertions(+), 10 deletions(-) diff --git a/static/app/views/dashboards/detail.tsx b/static/app/views/dashboards/detail.tsx index a57b11b5e82406..884e53e301e3ff 100644 --- a/static/app/views/dashboards/detail.tsx +++ b/static/app/views/dashboards/detail.tsx @@ -516,6 +516,14 @@ class DashboardDetail extends Component { ) : ['']; + filterParams[DashboardFilterKeys.TEMPORARY_FILTERS] = activeFilters[ + DashboardFilterKeys.TEMPORARY_FILTERS + ]?.length + ? activeFilters[DashboardFilterKeys.TEMPORARY_FILTERS].map(filter => + JSON.stringify(filter) + ) + : ['']; + if ( !isEqualWith(activeFilters, dashboard.filters, (a, b) => { // This is to handle the case where dashboard filters has release:[] and the new filter is release:"" diff --git a/static/app/views/dashboards/filtersBar.tsx b/static/app/views/dashboards/filtersBar.tsx index 1bba81779b887e..c924e302d041d6 100644 --- a/static/app/views/dashboards/filtersBar.tsx +++ b/static/app/views/dashboards/filtersBar.tsx @@ -106,9 +106,12 @@ export default function FiltersBar({ const updateGlobalFilters = (newGlobalFilters: GlobalFilter[]) => { setActiveGlobalFilters(newGlobalFilters); + const temporaryFilters = newGlobalFilters.filter(filter => filter.isTemporary); + const globalFilters = newGlobalFilters.filter(filter => !filter.isTemporary); onDashboardFilterChange({ [DashboardFilterKeys.RELEASE]: selectedReleases, - [DashboardFilterKeys.GLOBAL_FILTER]: newGlobalFilters, + [DashboardFilterKeys.GLOBAL_FILTER]: globalFilters, + [DashboardFilterKeys.TEMPORARY_FILTERS]: temporaryFilters, }); }; @@ -163,6 +166,7 @@ export default function FiltersBar({ {activeGlobalFilters.map(filter => ( void; onUpdateFilter: (filter: GlobalFilter) => void; searchBarData: SearchBarData; + disableRemoveFilter?: boolean; }; function FilterSelector({ @@ -57,6 +58,7 @@ function FilterSelector({ searchBarData, onRemoveFilter, onUpdateFilter, + disableRemoveFilter, }: FilterSelectorProps) { const {selection} = usePageFilters(); @@ -275,13 +277,15 @@ function FilterSelector({ {t('Clear')} )} - onRemoveFilter(globalFilter)} - > - {t('Remove Filter')} - + {!disableRemoveFilter && ( + onRemoveFilter(globalFilter)} + > + {t('Remove Filter')} + + )} ); diff --git a/static/app/views/dashboards/globalFilter/genericFilterSelector.tsx b/static/app/views/dashboards/globalFilter/genericFilterSelector.tsx index f13822ee42072f..ece28824a9d028 100644 --- a/static/app/views/dashboards/globalFilter/genericFilterSelector.tsx +++ b/static/app/views/dashboards/globalFilter/genericFilterSelector.tsx @@ -10,6 +10,7 @@ export type GenericFilterSelectorProps = { onRemoveFilter: (filter: GlobalFilter) => void; onUpdateFilter: (filter: GlobalFilter) => void; searchBarData: SearchBarData; + disableRemoveFilter?: boolean; }; function getFilterSelector( diff --git a/static/app/views/dashboards/types.tsx b/static/app/views/dashboards/types.tsx index d8693151401262..d481567867129d 100644 --- a/static/app/views/dashboards/types.tsx +++ b/static/app/views/dashboards/types.tsx @@ -192,6 +192,7 @@ export type GlobalFilter = { tag: Tag; // The raw filter condition string (e.g. 'tagKey:[values,...]') value: string; + isTemporary?: boolean; }; /** diff --git a/static/app/views/dashboards/utils.tsx b/static/app/views/dashboards/utils.tsx index bbb1d03fd65341..7947f9b95cb4ba 100644 --- a/static/app/views/dashboards/utils.tsx +++ b/static/app/views/dashboards/utils.tsx @@ -574,7 +574,7 @@ export function getDashboardFiltersFromURL(location: Location): DashboardFilters dashboardFilters[key] = queryFilters .map(filter => { try { - return JSON.parse(filter); + return {...JSON.parse(filter), isTemporary: true}; } catch (error) { return null; } @@ -633,16 +633,18 @@ export function getCombinedDashboardFilters( ): GlobalFilter[] { const finalFilters = [...(globalFilters ?? [])]; const temporaryFiltersCopy = [...(temporaryFilters ?? [])]; + finalFilters.forEach((filter, idx) => { // if a temporary filter exists for the same dataset and key, override it and delete it from the temporary filters to avoid duplicates const temporaryFilter = temporaryFiltersCopy.find( tf => tf.dataset === filter.dataset && tf.tag.key === filter.tag.key ); if (temporaryFilter) { - finalFilters[idx] = {...filter, value: temporaryFilter.value}; + finalFilters[idx] = {...temporaryFilter}; temporaryFiltersCopy.splice(temporaryFiltersCopy.indexOf(temporaryFilter), 1); } }); + return [...finalFilters, ...temporaryFiltersCopy]; } From 365dd7a0ddf9641a86d16a474bb4f786de6eef13 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Fri, 5 Dec 2025 16:38:33 -0500 Subject: [PATCH 2/9] update remove logic --- static/app/views/dashboards/detail.tsx | 2 +- static/app/views/dashboards/filtersBar.tsx | 22 +++++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/static/app/views/dashboards/detail.tsx b/static/app/views/dashboards/detail.tsx index 884e53e301e3ff..83b17cbdd53443 100644 --- a/static/app/views/dashboards/detail.tsx +++ b/static/app/views/dashboards/detail.tsx @@ -1195,7 +1195,7 @@ class DashboardDetail extends Component { isPreview={this.isPreview} onDashboardFilterChange={this.handleChangeFilter} shouldBusySaveButton={this.state.isSavingDashboardFilters} - isPrebuiltDashboard={defined(dashboard.prebuiltId)} + prebuiltDashboardId={dashboard.prebuiltId} onCancel={() => { resetPageFilters(dashboard, location); trackAnalytics('dashboards2.filter.cancel', { diff --git a/static/app/views/dashboards/filtersBar.tsx b/static/app/views/dashboards/filtersBar.tsx index c924e302d041d6..031961ce2292ca 100644 --- a/static/app/views/dashboards/filtersBar.tsx +++ b/static/app/views/dashboards/filtersBar.tsx @@ -11,6 +11,7 @@ import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilt import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {User} from 'sentry/types/user'; +import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import {ToggleOnDemand} from 'sentry/utils/performance/contexts/onDemandControl'; import {ReleasesProvider} from 'sentry/utils/releases/releasesProvider'; @@ -28,6 +29,10 @@ import { getCombinedDashboardFilters, getDashboardFiltersFromURL, } from 'sentry/views/dashboards/utils'; +import { + PREBUILT_DASHBOARDS, + type PrebuiltDashboardId, +} from 'sentry/views/dashboards/utils/prebuiltConfigs'; import {checkUserHasEditAccess} from './utils/checkUserHasEditAccess'; import ReleasesSelectControl from './releasesSelectControl'; @@ -44,9 +49,9 @@ type FiltersBarProps = { onDashboardFilterChange: (activeFilters: DashboardFilters) => void; dashboardCreator?: User; dashboardPermissions?: DashboardPermissions; - isPrebuiltDashboard?: boolean; onCancel?: () => void; onSave?: () => Promise; + prebuiltDashboardId?: PrebuiltDashboardId; shouldBusySaveButton?: boolean; }; @@ -63,7 +68,7 @@ export default function FiltersBar({ onDashboardFilterChange, onSave, shouldBusySaveButton, - isPrebuiltDashboard, + prebuiltDashboardId, }: FiltersBarProps) { const {selection} = usePageFilters(); const organization = useOrganization(); @@ -71,6 +76,10 @@ export default function FiltersBar({ const {teams: userTeams} = useUserTeams(); const getSearchBarData = useDatasetSearchBarData(); const hasDrillDownFlowsFeature = useHasDrillDownFlows(); + const isPrebuiltDashboard = defined(prebuiltDashboardId); + const prebuiltDashboardFilters: GlobalFilter[] = prebuiltDashboardId + ? (PREBUILT_DASHBOARDS[prebuiltDashboardId].filters.globalFilter ?? []) + : []; const hasEditAccess = checkUserHasEditAccess( currentUser, @@ -166,7 +175,14 @@ export default function FiltersBar({ {activeGlobalFilters.map(filter => ( + prebuiltFilter.tag.key === filter.tag.key && + prebuiltFilter.dataset === filter.dataset + ) + } key={filter.tag.key + filter.value} globalFilter={filter} searchBarData={getSearchBarData(filter.dataset)} From d908a59841a525eac02e37835ef355739f687acf Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Mon, 8 Dec 2025 11:56:25 -0500 Subject: [PATCH 3/9] add tests --- static/app/utils/discover/fieldRenderers.tsx | 3 +- static/app/views/dashboards/detail.tsx | 6 --- static/app/views/dashboards/filtersBar.tsx | 32 ++++--------- static/app/views/dashboards/types.tsx | 3 -- static/app/views/dashboards/utils.tsx | 48 ++------------------ 5 files changed, 15 insertions(+), 77 deletions(-) diff --git a/static/app/utils/discover/fieldRenderers.tsx b/static/app/utils/discover/fieldRenderers.tsx index 5c696d3ca78872..5ce3855a5fde65 100644 --- a/static/app/utils/discover/fieldRenderers.tsx +++ b/static/app/utils/discover/fieldRenderers.tsx @@ -1440,6 +1440,7 @@ function getDashboardUrl( dataset: widget.widgetType, tag: {key: field, name: field, kind: FieldKind.TAG}, value: formattedValue, + isTemporary: true, }); // Preserve project, environment, and time range query params @@ -1455,7 +1456,7 @@ function getDashboardUrl( const url = `/organizations/${organization.slug}/dashboard/${dashboardLink.dashboardId}/?${qs.stringify( { ...filterParams, - [DashboardFilterKeys.TEMPORARY_FILTERS]: newTemporaryFilters.map(filter => + [DashboardFilterKeys.GLOBAL_FILTER]: newTemporaryFilters.map(filter => JSON.stringify(filter) ), } diff --git a/static/app/views/dashboards/detail.tsx b/static/app/views/dashboards/detail.tsx index 83b17cbdd53443..ce81274f975e3d 100644 --- a/static/app/views/dashboards/detail.tsx +++ b/static/app/views/dashboards/detail.tsx @@ -968,7 +968,6 @@ class DashboardDetail extends Component { filters={{}} // Default Dashboards don't have filters set location={location} hasUnsavedChanges={false} - hasTemporaryFilters={false} isEditingDashboard={false} isPreview={false} onDashboardFilterChange={this.handleChangeFilter} @@ -1051,10 +1050,6 @@ class DashboardDetail extends Component { dashboardState !== DashboardState.CREATE && hasUnsavedFilterChanges(dashboard, location); - const hasTemporaryFilters = defined( - location.query?.[DashboardFilterKeys.TEMPORARY_FILTERS] - ); - const eventView = generatePerformanceEventView(location, projects, {}, organization); const isDashboardUsingTransaction = dashboard.widgets.some( @@ -1187,7 +1182,6 @@ class DashboardDetail extends Component { dashboardCreator={dashboard.createdBy} location={location} hasUnsavedChanges={!this.isEmbedded && hasUnsavedFilters} - hasTemporaryFilters={hasTemporaryFilters} isEditingDashboard={ dashboardState !== DashboardState.CREATE && this.isEditingDashboard diff --git a/static/app/views/dashboards/filtersBar.tsx b/static/app/views/dashboards/filtersBar.tsx index 031961ce2292ca..ed937bc0ec9441 100644 --- a/static/app/views/dashboards/filtersBar.tsx +++ b/static/app/views/dashboards/filtersBar.tsx @@ -23,12 +23,8 @@ import AddFilter from 'sentry/views/dashboards/globalFilter/addFilter'; import GenericFilterSelector from 'sentry/views/dashboards/globalFilter/genericFilterSelector'; import {globalFilterKeysAreEqual} from 'sentry/views/dashboards/globalFilter/utils'; import {useDatasetSearchBarData} from 'sentry/views/dashboards/hooks/useDatasetSearchBarData'; -import {useHasDrillDownFlows} from 'sentry/views/dashboards/hooks/useHasDrillDownFlows'; import {useInvalidateStarredDashboards} from 'sentry/views/dashboards/hooks/useInvalidateStarredDashboards'; -import { - getCombinedDashboardFilters, - getDashboardFiltersFromURL, -} from 'sentry/views/dashboards/utils'; +import {getDashboardFiltersFromURL} from 'sentry/views/dashboards/utils'; import { PREBUILT_DASHBOARDS, type PrebuiltDashboardId, @@ -39,9 +35,8 @@ import ReleasesSelectControl from './releasesSelectControl'; import type {DashboardFilters, DashboardPermissions, GlobalFilter} from './types'; import {DashboardFilterKeys} from './types'; -type FiltersBarProps = { +export type FiltersBarProps = { filters: DashboardFilters; - hasTemporaryFilters: boolean; hasUnsavedChanges: boolean; isEditingDashboard: boolean; isPreview: boolean; @@ -57,7 +52,6 @@ type FiltersBarProps = { export default function FiltersBar({ filters, - hasTemporaryFilters, dashboardPermissions, dashboardCreator, hasUnsavedChanges, @@ -75,7 +69,6 @@ export default function FiltersBar({ const currentUser = useUser(); const {teams: userTeams} = useUserTeams(); const getSearchBarData = useDatasetSearchBarData(); - const hasDrillDownFlowsFeature = useHasDrillDownFlows(); const isPrebuiltDashboard = defined(prebuiltDashboardId); const prebuiltDashboardFilters: GlobalFilter[] = prebuiltDashboardId ? (PREBUILT_DASHBOARDS[prebuiltDashboardId].filters.globalFilter ?? []) @@ -98,32 +91,23 @@ export default function FiltersBar({ []; const [activeGlobalFilters, setActiveGlobalFilters] = useState(() => { - const globalFilters = + return ( dashboardFiltersFromURL?.[DashboardFilterKeys.GLOBAL_FILTER] ?? filters?.[DashboardFilterKeys.GLOBAL_FILTER] ?? - []; - - if (hasDrillDownFlowsFeature && dashboardFiltersFromURL) { - return getCombinedDashboardFilters( - globalFilters, - dashboardFiltersFromURL?.[DashboardFilterKeys.TEMPORARY_FILTERS] - ); - } - - return globalFilters; + [] + ); }); const updateGlobalFilters = (newGlobalFilters: GlobalFilter[]) => { setActiveGlobalFilters(newGlobalFilters); - const temporaryFilters = newGlobalFilters.filter(filter => filter.isTemporary); - const globalFilters = newGlobalFilters.filter(filter => !filter.isTemporary); onDashboardFilterChange({ [DashboardFilterKeys.RELEASE]: selectedReleases, - [DashboardFilterKeys.GLOBAL_FILTER]: globalFilters, - [DashboardFilterKeys.TEMPORARY_FILTERS]: temporaryFilters, + [DashboardFilterKeys.GLOBAL_FILTER]: newGlobalFilters, }); }; + const hasTemporaryFilters = activeGlobalFilters.some(filter => filter.isTemporary); + return ( diff --git a/static/app/views/dashboards/types.tsx b/static/app/views/dashboards/types.tsx index d481567867129d..a9fe12c688f183 100644 --- a/static/app/views/dashboards/types.tsx +++ b/static/app/views/dashboards/types.tsx @@ -175,14 +175,11 @@ export type DashboardListItem = { export enum DashboardFilterKeys { RELEASE = 'release', GLOBAL_FILTER = 'globalFilter', - // temporary filters are filters that are not saved to the dashboard, they occur when you link from one dashboard to another - TEMPORARY_FILTERS = 'temporaryFilters', } export type DashboardFilters = { [DashboardFilterKeys.RELEASE]?: string[]; [DashboardFilterKeys.GLOBAL_FILTER]?: GlobalFilter[]; - [DashboardFilterKeys.TEMPORARY_FILTERS]?: GlobalFilter[]; }; export type GlobalFilter = { diff --git a/static/app/views/dashboards/utils.tsx b/static/app/views/dashboards/utils.tsx index 7947f9b95cb4ba..63096e47807296 100644 --- a/static/app/views/dashboards/utils.tsx +++ b/static/app/views/dashboards/utils.tsx @@ -46,7 +46,6 @@ import {decodeList, decodeScalar} from 'sentry/utils/queryString'; import type { DashboardDetails, DashboardFilters, - GlobalFilter, Widget, WidgetQuery, } from 'sentry/views/dashboards/types'; @@ -570,16 +569,6 @@ export function getDashboardFiltersFromURL(location: Location): DashboardFilters } }) .filter(filter => filter !== null); - } else if (key === DashboardFilterKeys.TEMPORARY_FILTERS) { - dashboardFilters[key] = queryFilters - .map(filter => { - try { - return {...JSON.parse(filter), isTemporary: true}; - } catch (error) { - return null; - } - }) - .filter(filter => filter !== null); } else { dashboardFilters[key] = queryFilters; } @@ -594,10 +583,7 @@ export function dashboardFiltersToString( ): string { let dashboardFilterConditions = ''; - const pinnedFilters = omit(dashboardFilters, [ - DashboardFilterKeys.GLOBAL_FILTER, - DashboardFilterKeys.TEMPORARY_FILTERS, - ]); + const pinnedFilters = omit(dashboardFilters, DashboardFilterKeys.GLOBAL_FILTER); if (pinnedFilters) { for (const [key, activeFilters] of Object.entries(pinnedFilters)) { if (activeFilters.length === 1) { @@ -610,14 +596,12 @@ export function dashboardFiltersToString( } } - const combinedFilters = getCombinedDashboardFilters( - dashboardFilters?.[DashboardFilterKeys.GLOBAL_FILTER], - dashboardFilters?.[DashboardFilterKeys.TEMPORARY_FILTERS] - ); + const globalFilters = dashboardFilters?.[DashboardFilterKeys.GLOBAL_FILTER]; + // If widgetType is provided, concatenate global filters that apply - if (widgetType && combinedFilters) { + if (widgetType && globalFilters) { dashboardFilterConditions += - combinedFilters + globalFilters .filter(globalFilter => globalFilter.dataset === widgetType) .map(globalFilter => globalFilter.value) .join(' ') ?? ''; @@ -626,28 +610,6 @@ export function dashboardFiltersToString( return dashboardFilterConditions; } -// Combines global and temporary filters into a single array, deduplicating by dataset and key prioritizing the temporary filter. -export function getCombinedDashboardFilters( - globalFilters?: GlobalFilter[], - temporaryFilters?: GlobalFilter[] -): GlobalFilter[] { - const finalFilters = [...(globalFilters ?? [])]; - const temporaryFiltersCopy = [...(temporaryFilters ?? [])]; - - finalFilters.forEach((filter, idx) => { - // if a temporary filter exists for the same dataset and key, override it and delete it from the temporary filters to avoid duplicates - const temporaryFilter = temporaryFiltersCopy.find( - tf => tf.dataset === filter.dataset && tf.tag.key === filter.tag.key - ); - if (temporaryFilter) { - finalFilters[idx] = {...temporaryFilter}; - temporaryFiltersCopy.splice(temporaryFiltersCopy.indexOf(temporaryFilter), 1); - } - }); - - return [...finalFilters, ...temporaryFiltersCopy]; -} - export function connectDashboardCharts(groupName: string) { connect?.(groupName); } From 7df88dfe7319f083ce0f08cdba0496928d1599c2 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Mon, 8 Dec 2025 11:57:11 -0500 Subject: [PATCH 4/9] tests --- .../app/views/dashboards/filtersBar.spec.tsx | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 static/app/views/dashboards/filtersBar.spec.tsx diff --git a/static/app/views/dashboards/filtersBar.spec.tsx b/static/app/views/dashboards/filtersBar.spec.tsx new file mode 100644 index 00000000000000..941b222eda2ca7 --- /dev/null +++ b/static/app/views/dashboards/filtersBar.spec.tsx @@ -0,0 +1,153 @@ +// create a basic test for filters bar + +import {LocationFixture} from 'sentry-fixture/locationFixture'; +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ReleaseFixture} from 'sentry-fixture/release'; +import {TagsFixture} from 'sentry-fixture/tags'; + +import {render, screen, waitForElementToBeRemoved} from 'sentry-test/reactTestingLibrary'; + +import type {Organization} from 'sentry/types/organization'; +import {FieldKind} from 'sentry/utils/fields'; +import FiltersBar, {type FiltersBarProps} from 'sentry/views/dashboards/filtersBar'; +import { + DashboardFilterKeys, + WidgetType, + type GlobalFilter, +} from 'sentry/views/dashboards/types'; + +describe('FiltersBar', () => { + let organization: Organization; + + beforeEach(() => { + mockNetworkRequests(); + + organization = OrganizationFixture({ + features: ['dashboards-basic', 'dashboards-edit', 'dashboards-global-filters'], + }); + }); + + afterEach(() => { + MockApiClient.clearMockResponses(); + jest.clearAllMocks(); + }); + + const renderFilterBar = (overrides: Partial = {}) => { + const props: FiltersBarProps = { + filters: {}, + hasTemporaryFilters: false, + hasUnsavedChanges: false, + isEditingDashboard: false, + isPreview: false, + location: LocationFixture(), + onDashboardFilterChange: () => {}, + ...overrides, + }; + + return render(, {organization}); + }; + + it('should render basic global filter', async () => { + const newLocation = LocationFixture({ + query: { + [DashboardFilterKeys.GLOBAL_FILTER]: JSON.stringify({ + dataset: WidgetType.SPANS, + tag: {key: 'browser.name', name: 'Browser Name', kind: FieldKind.FIELD}, + value: `browser.name:[Chrome]`, + } satisfies GlobalFilter), + }, + }); + renderFilterBar({location: newLocation}); + await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); + expect( + screen.getByRole('button', {name: /browser\.name.*Chrome/i}) + ).toBeInTheDocument(); + }); + + it('should render save button with unsaved changes', async () => { + const newLocation = LocationFixture({ + query: { + [DashboardFilterKeys.GLOBAL_FILTER]: JSON.stringify({ + dataset: WidgetType.SPANS, + tag: {key: 'browser.name', name: 'Browser Name', kind: FieldKind.FIELD}, + value: `browser.name:[Chrome]`, + } satisfies GlobalFilter), + }, + }); + renderFilterBar({location: newLocation, hasUnsavedChanges: true}); + await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); + expect( + screen.getByRole('button', {name: /browser\.name.*Chrome/i}) + ).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Cancel'})).toBeInTheDocument(); + }); + + it('should not render save button with temporary filter', async () => { + const newLocation = LocationFixture({ + query: { + [DashboardFilterKeys.GLOBAL_FILTER]: JSON.stringify({ + dataset: WidgetType.SPANS, + tag: {key: 'browser.name', name: 'Browser Name', kind: FieldKind.FIELD}, + value: `browser.name:[Chrome]`, + isTemporary: true, + } satisfies GlobalFilter), + }, + }); + + renderFilterBar({location: newLocation}); + await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); + expect( + screen.getByRole('button', {name: /browser\.name.*Chrome/i}) + ).toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Cancel'})).not.toBeInTheDocument(); + }); +}); + +const mockNetworkRequests = () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/releases/', + body: [ReleaseFixture()], + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/tags/', + body: TagsFixture(), + }); + MockApiClient.addMockResponse({ + url: `/organizations/org-slug/measurements-meta/`, + body: { + 'measurements.custom.measurement': { + functions: ['p99'], + }, + 'measurements.another.custom.measurement': { + functions: ['p99'], + }, + }, + }); + + const mockSearchResponse = [ + { + key: 'browser.name', + value: 'Chrome', + name: 'Chrome', + first_seen: null, + last_seen: null, + times_seen: null, + }, + { + key: 'browser.name', + value: 'Firefox', + name: 'Firefox', + first_seen: null, + last_seen: null, + times_seen: null, + }, + ]; + + MockApiClient.addMockResponse({ + url: `/organizations/org-slug/trace-items/attributes/browser.name/values/`, + body: mockSearchResponse, + match: [MockApiClient.matchQuery({attributeType: 'string'})], + }); +}; From 59a2f78ed287a42f1ce5d292cd9f7a7593c9e051 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Mon, 8 Dec 2025 12:05:00 -0500 Subject: [PATCH 5/9] add remove filter logic to numeric filters --- .../app/views/dashboards/filtersBar.spec.tsx | 26 ++++++++++++++++--- .../globalFilter/numericFilterSelector.tsx | 23 +++++++++------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/static/app/views/dashboards/filtersBar.spec.tsx b/static/app/views/dashboards/filtersBar.spec.tsx index 941b222eda2ca7..72bcef5ee58f10 100644 --- a/static/app/views/dashboards/filtersBar.spec.tsx +++ b/static/app/views/dashboards/filtersBar.spec.tsx @@ -15,6 +15,7 @@ import { WidgetType, type GlobalFilter, } from 'sentry/views/dashboards/types'; +import {PrebuiltDashboardId} from 'sentry/views/dashboards/utils/prebuiltConfigs'; describe('FiltersBar', () => { let organization: Organization; @@ -76,9 +77,6 @@ describe('FiltersBar', () => { }); renderFilterBar({location: newLocation, hasUnsavedChanges: true}); await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); - expect( - screen.getByRole('button', {name: /browser\.name.*Chrome/i}) - ).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Cancel'})).toBeInTheDocument(); }); @@ -103,6 +101,28 @@ describe('FiltersBar', () => { expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument(); expect(screen.queryByRole('button', {name: 'Cancel'})).not.toBeInTheDocument(); }); + + it('should not render save button on prebuilt dashboard', async () => { + const newLocation = LocationFixture({ + query: { + [DashboardFilterKeys.GLOBAL_FILTER]: JSON.stringify({ + dataset: WidgetType.SPANS, + tag: {key: 'browser.name', name: 'Browser Name', kind: FieldKind.FIELD}, + value: `browser.name:[Chrome]`, + } satisfies GlobalFilter), + }, + }); + renderFilterBar({ + location: newLocation, + prebuiltDashboardId: PrebuiltDashboardId.FRONTEND_SESSION_HEALTH, + }); + await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); + expect( + screen.getByRole('button', {name: /browser\.name.*Chrome/i}) + ).toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Cancel'})).not.toBeInTheDocument(); + }); }); const mockNetworkRequests = () => { diff --git a/static/app/views/dashboards/globalFilter/numericFilterSelector.tsx b/static/app/views/dashboards/globalFilter/numericFilterSelector.tsx index fb87b42dc09672..f95ec3dd077a9e 100644 --- a/static/app/views/dashboards/globalFilter/numericFilterSelector.tsx +++ b/static/app/views/dashboards/globalFilter/numericFilterSelector.tsx @@ -204,6 +204,7 @@ function NumericFilterSelector({ globalFilter, onRemoveFilter, onUpdateFilter, + disableRemoveFilter, }: GenericFilterSelectorProps) { const globalFilterQueries = useMemo( () => globalFilter.value.split(FILTER_QUERY_SEPARATOR), @@ -291,15 +292,19 @@ function NumericFilterSelector({ {t('%s Filter', getDatasetLabel(globalFilter.dataset))} } - menuHeaderTrailingItems={() => ( - onRemoveFilter(globalFilter)} - > - {t('Remove Filter')} - - )} + menuHeaderTrailingItems={ + disableRemoveFilter + ? undefined + : () => ( + onRemoveFilter(globalFilter)} + > + {t('Remove Filter')} + + ) + } menuBody={ From 828b93e718c143e4726a401f9df4ab1d36a8448d Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Mon, 8 Dec 2025 12:10:07 -0500 Subject: [PATCH 6/9] fix --- static/app/views/dashboards/detail.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/static/app/views/dashboards/detail.tsx b/static/app/views/dashboards/detail.tsx index ce81274f975e3d..def3a806e43390 100644 --- a/static/app/views/dashboards/detail.tsx +++ b/static/app/views/dashboards/detail.tsx @@ -516,14 +516,6 @@ class DashboardDetail extends Component { ) : ['']; - filterParams[DashboardFilterKeys.TEMPORARY_FILTERS] = activeFilters[ - DashboardFilterKeys.TEMPORARY_FILTERS - ]?.length - ? activeFilters[DashboardFilterKeys.TEMPORARY_FILTERS].map(filter => - JSON.stringify(filter) - ) - : ['']; - if ( !isEqualWith(activeFilters, dashboard.filters, (a, b) => { // This is to handle the case where dashboard filters has release:[] and the new filter is release:"" From 7c4531c5abc1448a2cc1f3160698a64fa6ff78c7 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Mon, 8 Dec 2025 12:25:55 -0500 Subject: [PATCH 7/9] add tests --- .../utils/discover/fieldRenderers.spec.tsx | 45 ++++++++++++++++++- .../app/views/dashboards/filtersBar.spec.tsx | 1 - 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/static/app/utils/discover/fieldRenderers.spec.tsx b/static/app/utils/discover/fieldRenderers.spec.tsx index 2994fe4f1a489e..97408287129520 100644 --- a/static/app/utils/discover/fieldRenderers.spec.tsx +++ b/static/app/utils/discover/fieldRenderers.spec.tsx @@ -1,5 +1,7 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; import {ThemeFixture} from 'sentry-fixture/theme'; import {UserFixture} from 'sentry-fixture/user'; +import {WidgetFixture} from 'sentry-fixture/widget'; import {initializeOrg} from 'sentry-test/initializeOrg'; import {act, render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; @@ -8,6 +10,7 @@ import ProjectsStore from 'sentry/stores/projectsStore'; import EventView from 'sentry/utils/discover/eventView'; import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers'; import {SPAN_OP_RELATIVE_BREAKDOWN_FIELD} from 'sentry/utils/discover/fields'; +import {WidgetType, type DashboardFilters} from 'sentry/views/dashboards/types'; const theme = ThemeFixture(); @@ -15,7 +18,9 @@ describe('getFieldRenderer', () => { let location: any, context: any, project: any, organization: any, data: any, user: any; beforeEach(() => { - context = initializeOrg(); + context = initializeOrg({ + organization: OrganizationFixture({features: ['dashboards-drilldown-flow']}), + }); organization = context.organization; project = context.project; act(() => ProjectsStore.loadInitialData([project])); @@ -113,6 +118,44 @@ describe('getFieldRenderer', () => { expect(screen.getByText(data.numeric)).toBeInTheDocument(); }); + it('can render dashboard links', () => { + const widget = WidgetFixture({ + widgetType: WidgetType.SPANS, + queries: [ + { + linkedDashboards: [{dashboardId: '123', field: 'transaction'}], + aggregates: [], + columns: [], + conditions: '', + name: '', + orderby: '', + }, + ], + }); + const dashboardFilters: DashboardFilters = {}; + + const renderer = getFieldRenderer( + 'transaction', + {transaction: 'string'}, + undefined, + widget, + dashboardFilters + ); + + render( + renderer(data, { + location, + organization, + theme, + }) as React.ReactElement + ); + + expect(screen.getByRole('link')).toHaveAttribute( + 'href', + '/organizations/org-slug/dashboard/123/?globalFilter=%7B%22dataset%22%3A%22spans%22%2C%22tag%22%3A%7B%22key%22%3A%22transaction%22%2C%22name%22%3A%22transaction%22%2C%22kind%22%3A%22tag%22%7D%2C%22value%22%3A%22transaction%3A%5Bapi.do_things%5D%22%2C%22isTemporary%22%3Atrue%7D' + ); + }); + describe('rate', () => { it('can render null rate', () => { const renderer = getFieldRenderer( diff --git a/static/app/views/dashboards/filtersBar.spec.tsx b/static/app/views/dashboards/filtersBar.spec.tsx index 72bcef5ee58f10..87fe071c139180 100644 --- a/static/app/views/dashboards/filtersBar.spec.tsx +++ b/static/app/views/dashboards/filtersBar.spec.tsx @@ -36,7 +36,6 @@ describe('FiltersBar', () => { const renderFilterBar = (overrides: Partial = {}) => { const props: FiltersBarProps = { filters: {}, - hasTemporaryFilters: false, hasUnsavedChanges: false, isEditingDashboard: false, isPreview: false, From 842d629f4754558883a4f6690d5773696287a1fa Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Mon, 8 Dec 2025 12:29:29 -0500 Subject: [PATCH 8/9] deduplicate --- static/app/utils/discover/fieldRenderers.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/static/app/utils/discover/fieldRenderers.tsx b/static/app/utils/discover/fieldRenderers.tsx index 5ce3855a5fde65..bddab14f364278 100644 --- a/static/app/utils/discover/fieldRenderers.tsx +++ b/static/app/utils/discover/fieldRenderers.tsx @@ -1430,7 +1430,12 @@ function getDashboardUrl( if (dashboardLink && dashboardLink.dashboardId !== '-1') { const newTemporaryFilters: GlobalFilter[] = [ ...(dashboardFilters[DashboardFilterKeys.GLOBAL_FILTER] ?? []), - ].filter(filter => Boolean(filter.value)); + ].filter( + filter => + Boolean(filter.value) && + filter.tag.key !== field && + filter.dataset !== widget.widgetType + ); // Format the value as a proper filter condition string const mutableSearch = new MutableSearch(''); From 2f9c008ecc015f00c4839f27cd43df015eebfb30 Mon Sep 17 00:00:00 2001 From: Dominik Buszowiecki Date: Mon, 8 Dec 2025 12:55:31 -0500 Subject: [PATCH 9/9] code review --- static/app/utils/discover/fieldRenderers.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/static/app/utils/discover/fieldRenderers.tsx b/static/app/utils/discover/fieldRenderers.tsx index bddab14f364278..5cb640999985de 100644 --- a/static/app/utils/discover/fieldRenderers.tsx +++ b/static/app/utils/discover/fieldRenderers.tsx @@ -1433,8 +1433,7 @@ function getDashboardUrl( ].filter( filter => Boolean(filter.value) && - filter.tag.key !== field && - filter.dataset !== widget.widgetType + !(filter.tag.key === field && filter.dataset === widget.widgetType) ); // Format the value as a proper filter condition string