From a45b74fb1482e6b685bd42ce2f62337d34bde115 Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:53:28 -0400 Subject: [PATCH 1/2] fix(dashboards): Copy saved filters when duplicating prebuilt dashboards Fetch the saved dashboard details from the API when duplicating a prebuilt dashboard so that the user's saved filter values are preserved on the copy. --- static/app/views/dashboards/controls.tsx | 3 +- .../hooks/useDuplicateDashboard.spec.tsx | 60 +++++++++++++++++-- .../hooks/useDuplicateDashboard.tsx | 24 ++++++-- 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/static/app/views/dashboards/controls.tsx b/static/app/views/dashboards/controls.tsx index a5c46ba7859baa..2a7549c76fffd2 100644 --- a/static/app/views/dashboards/controls.tsx +++ b/static/app/views/dashboards/controls.tsx @@ -365,8 +365,7 @@ export function Controls({ 'Are you sure you want to duplicate this dashboard?' ), priority: 'primary', - onConfirm: () => - duplicatePrebuiltDashboard(dashboard.prebuiltId), + onConfirm: () => duplicatePrebuiltDashboard(dashboard.id), }); }} icon={isLoading ? : } diff --git a/static/app/views/dashboards/hooks/useDuplicateDashboard.spec.tsx b/static/app/views/dashboards/hooks/useDuplicateDashboard.spec.tsx index 59ee427aaa7941..9c2486c4aeaa8e 100644 --- a/static/app/views/dashboards/hooks/useDuplicateDashboard.spec.tsx +++ b/static/app/views/dashboards/hooks/useDuplicateDashboard.spec.tsx @@ -86,13 +86,63 @@ describe('useDuplicatePrebuiltDashboard', () => { MockApiClient.clearMockResponses(); }); - it('resolves and duplicates a prebuilt dashboard', async () => { + it('fetches saved dashboard details and duplicates with saved filters', async () => { + const savedFilters = { + globalFilter: [ + { + dataset: 'spans' as const, + tag: {key: 'db.normalized_description', name: 'db.normalized_description'}, + value: '*billing*', + }, + ], + }; + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/dashboards/55/`, + body: DashboardFixture([], { + id: '55', + prebuiltId: PrebuiltDashboardId.BACKEND_QUERIES_SUMMARY, + filters: savedFilters, + }), + }); + const createMock = MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/dashboards/`, + method: 'POST', + body: DashboardFixture([], {id: '300'}), + }); + + const onSuccess = jest.fn(); + const {result} = renderHookWithProviders( + () => useDuplicatePrebuiltDashboard({onSuccess}), + {organization} + ); + + await act(async () => { + await result.current.duplicatePrebuiltDashboard('55'); + }); + + expect(createMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + data: expect.objectContaining({filters: savedFilters}), + }) + ); + expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({id: '300'})); + }); + + it('resolves linked dashboard IDs from static config', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/dashboards/55/`, + body: DashboardFixture([], { + id: '55', + prebuiltId: PrebuiltDashboardId.WEB_VITALS, + }), + }); MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/dashboards/`, method: 'GET', body: [ DashboardFixture([], { - id: '55', + id: '77', prebuiltId: PrebuiltDashboardId.WEB_VITALS_SUMMARY, }), ], @@ -110,20 +160,20 @@ describe('useDuplicatePrebuiltDashboard', () => { ); await act(async () => { - await result.current.duplicatePrebuiltDashboard(PrebuiltDashboardId.WEB_VITALS); + await result.current.duplicatePrebuiltDashboard('55'); }); expect(createMock).toHaveBeenCalled(); expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({id: '300'})); }); - it('throws when no prebuiltId is provided', async () => { + it('throws when no dashboardId is provided', async () => { const {result} = renderHookWithProviders(() => useDuplicatePrebuiltDashboard({}), { organization, }); await expect(result.current.duplicatePrebuiltDashboard(undefined)).rejects.toThrow( - 'Prebuilt dashboard ID is required' + 'Dashboard ID is required' ); }); }); diff --git a/static/app/views/dashboards/hooks/useDuplicateDashboard.tsx b/static/app/views/dashboards/hooks/useDuplicateDashboard.tsx index 8a3a277a8bd23d..0f016fa3cdcc41 100644 --- a/static/app/views/dashboards/hooks/useDuplicateDashboard.tsx +++ b/static/app/views/dashboards/hooks/useDuplicateDashboard.tsx @@ -67,23 +67,35 @@ export function useDuplicatePrebuiltDashboard({onSuccess}: UseDuplicateDashboard const [isLoading, setIsLoading] = useState(false); const duplicatePrebuiltDashboard = useCallback( - async (prebuiltId?: PrebuiltDashboardId) => { - if (!prebuiltId) { - throw new Error( - 'Prebuilt dashboard ID is required to duplicate a prebuilt dashboard' - ); + async (dashboardId?: string) => { + if (!dashboardId) { + throw new Error('Dashboard ID is required to duplicate a prebuilt dashboard'); } try { setIsLoading(true); + + // Fetch the saved dashboard to get the prebuilt ID and saved filters. + // Widgets are not stored for prebuilt dashboards, so we pull those + // from the static config and resolve any linked dashboard placeholders. + const savedDashboard = await fetchDashboard(api, organization.slug, dashboardId); + + if (!savedDashboard.prebuiltId) { + throw new Error('Saved dashboard is missing its prebuilt ID'); + } + const dashboardDetail = await resolveLinkedDashboardIds({ queryClient, orgSlug: organization.slug, - dashboard: toPrebuiltDashboardDetails(prebuiltId), + dashboard: toPrebuiltDashboardDetails(savedDashboard.prebuiltId), }); + const newDashboard = cloneDashboard(dashboardDetail); delete newDashboard.prebuiltId; newDashboard.title = `${newDashboard.title} copy`; newDashboard.widgets.map(widget => (widget.id = undefined)); + if (savedDashboard.filters !== undefined) { + newDashboard.filters = savedDashboard.filters; + } const copiedDashboard = await createDashboard( api, organization.slug, From 2a0c18f0cf58168e02253fde89a03d8e24b0e94a Mon Sep 17 00:00:00 2001 From: George Gritsouk <989898+gggritso@users.noreply.github.com> Date: Wed, 18 Mar 2026 19:53:41 -0400 Subject: [PATCH 2/2] fix(dashboards): Use proper WidgetType enum in test filters --- .../views/dashboards/hooks/useDuplicateDashboard.spec.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/static/app/views/dashboards/hooks/useDuplicateDashboard.spec.tsx b/static/app/views/dashboards/hooks/useDuplicateDashboard.spec.tsx index 9c2486c4aeaa8e..1bea2595c12150 100644 --- a/static/app/views/dashboards/hooks/useDuplicateDashboard.spec.tsx +++ b/static/app/views/dashboards/hooks/useDuplicateDashboard.spec.tsx @@ -8,6 +8,8 @@ import { useDuplicateDashboard, useDuplicatePrebuiltDashboard, } from 'sentry/views/dashboards/hooks/useDuplicateDashboard'; +import type {DashboardFilters} from 'sentry/views/dashboards/types'; +import {WidgetType} from 'sentry/views/dashboards/types'; import {PrebuiltDashboardId} from 'sentry/views/dashboards/utils/prebuiltConfigs'; describe('useDuplicateDashboard', () => { @@ -87,10 +89,10 @@ describe('useDuplicatePrebuiltDashboard', () => { }); it('fetches saved dashboard details and duplicates with saved filters', async () => { - const savedFilters = { + const savedFilters: DashboardFilters = { globalFilter: [ { - dataset: 'spans' as const, + dataset: WidgetType.SPANS, tag: {key: 'db.normalized_description', name: 'db.normalized_description'}, value: '*billing*', },