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*',
},