Skip to content

Commit e3a640e

Browse files
authored
fix(tracemetrics): Add 'new query' for save-as behaviour (#103734)
<img width="221" height="235" alt="Screenshot 2025-11-20 at 12 15 55 PM" src="https://github.com/user-attachments/assets/26b3bfde-2a8e-4a15-8e7a-94c19177d8f6" /> Closes LOGS-511
1 parent fed6c09 commit e3a640e

File tree

4 files changed

+287
-16
lines changed

4 files changed

+287
-16
lines changed

static/app/views/explore/logs/useSaveAsItems.spec.tsx

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ describe('useSaveAsItems', () => {
103103
});
104104
});
105105

106-
it('should open save query modal when save as query is clicked', () => {
106+
it('should open save query modal when save as new query is clicked', () => {
107107
const {result} = renderHook(
108108
() =>
109109
useSaveAsItems({
@@ -132,6 +132,87 @@ describe('useSaveAsItems', () => {
132132
});
133133
});
134134

135+
it('should show both existing and new query options when saved query exists', async () => {
136+
MockApiClient.addMockResponse({
137+
url: `/organizations/${organization.slug}/explore/saved/test-query-id/`,
138+
body: {
139+
id: 'test-query-id',
140+
name: 'Test Query',
141+
isPrebuilt: false,
142+
query: [{}],
143+
dateAdded: '2024-01-01T00:00:00.000Z',
144+
dateUpdated: '2024-01-01T00:00:00.000Z',
145+
interval: '5m',
146+
lastVisited: '2024-01-01T00:00:00.000Z',
147+
position: null,
148+
projects: [1],
149+
dataset: 'logs',
150+
starred: false,
151+
},
152+
});
153+
154+
mockedUseLocation.mockReturnValue(
155+
LocationFixture({
156+
query: {
157+
id: 'test-query-id',
158+
logsFields: ['timestamp', 'message'],
159+
logsQuery: 'message:"test"',
160+
mode: 'aggregate',
161+
},
162+
})
163+
);
164+
165+
const {result} = renderHook(
166+
() =>
167+
useSaveAsItems({
168+
visualizes: [new VisualizeFunction('count()')],
169+
groupBys: ['message.template'],
170+
interval: '5m',
171+
mode: Mode.AGGREGATE,
172+
search: new MutableSearch('message:"test"'),
173+
sortBys: [{field: 'timestamp', kind: 'desc'}],
174+
}),
175+
{wrapper: createWrapper()}
176+
);
177+
178+
await waitFor(() => {
179+
expect(result.current.some(item => item.key === 'update-query')).toBe(true);
180+
});
181+
182+
const saveAsItems = result.current;
183+
expect(saveAsItems.some(item => item.key === 'save-query')).toBe(true);
184+
});
185+
186+
it('should show only new query option when no saved query exists', () => {
187+
mockedUseLocation.mockReturnValue(
188+
LocationFixture({
189+
query: {
190+
logsFields: ['timestamp', 'message'],
191+
logsQuery: 'message:"test"',
192+
mode: 'aggregate',
193+
},
194+
})
195+
);
196+
197+
const {result} = renderHook(
198+
() =>
199+
useSaveAsItems({
200+
visualizes: [new VisualizeFunction('count()')],
201+
groupBys: ['message.template'],
202+
interval: '5m',
203+
mode: Mode.AGGREGATE,
204+
search: new MutableSearch('message:"test"'),
205+
sortBys: [{field: 'timestamp', kind: 'desc'}],
206+
}),
207+
{wrapper: createWrapper()}
208+
);
209+
210+
const saveAsItems = result.current;
211+
212+
expect(saveAsItems.some(item => item.key === 'update-query')).toBe(false);
213+
expect(saveAsItems.some(item => item.key === 'save-query')).toBe(true);
214+
});
215+
135216
it('should call saveQuery with correct parameters when modal saves', async () => {
136217
const {result} = renderHook(
137218
() =>

static/app/views/explore/logs/useSaveAsItems.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,10 @@ export function useSaveAsItems({
7777
);
7878

7979
const saveAsQuery = useMemo(() => {
80-
// Show "Existing Query" if we have a non-prebuilt saved query, otherwise "A New Query"
80+
const items = [];
81+
8182
if (defined(id) && savedQuery?.isPrebuilt === false) {
82-
return {
83+
items.push({
8384
key: 'update-query',
8485
textValue: t('Existing Query'),
8586
label: <span>{t('Existing Query')}</span>,
@@ -98,10 +99,10 @@ export function useSaveAsItems({
9899
Sentry.captureException(error);
99100
}
100101
},
101-
};
102+
});
102103
}
103104

104-
return {
105+
items.push({
105106
key: 'save-query',
106107
label: <span>{t('A New Query')}</span>,
107108
textValue: t('A New Query'),
@@ -119,7 +120,9 @@ export function useSaveAsItems({
119120
traceItemDataset: TraceItemDataset.LOGS,
120121
});
121122
},
122-
};
123+
});
124+
125+
return items;
123126
}, [id, savedQuery?.isPrebuilt, updateQuery, saveQuery, organization]);
124127

125128
const saveAsAlert = useMemo(() => {
@@ -235,7 +238,7 @@ export function useSaveAsItems({
235238
return useMemo(() => {
236239
const saveAs = [];
237240
if (isLogsEnabled(organization)) {
238-
saveAs.push(saveAsQuery);
241+
saveAs.push(...saveAsQuery);
239242
saveAs.push(saveAsAlert);
240243
saveAs.push(saveAsDashboard);
241244
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import {LocationFixture} from 'sentry-fixture/locationFixture';
2+
import {OrganizationFixture} from 'sentry-fixture/organization';
3+
import {ProjectFixture} from 'sentry-fixture/project';
4+
5+
import {makeTestQueryClient} from 'sentry-test/queryClient';
6+
import {renderHook, waitFor} from 'sentry-test/reactTestingLibrary';
7+
8+
import * as modal from 'sentry/actionCreators/modal';
9+
import ProjectsStore from 'sentry/stores/projectsStore';
10+
import {QueryClientProvider} from 'sentry/utils/queryClient';
11+
import {useLocation} from 'sentry/utils/useLocation';
12+
import {useNavigate} from 'sentry/utils/useNavigate';
13+
import {MockMetricQueryParamsContext} from 'sentry/views/explore/metrics/hooks/testUtils';
14+
import {useSaveAsMetricItems} from 'sentry/views/explore/metrics/useSaveAsMetricItems';
15+
import {OrganizationContext} from 'sentry/views/organizationContext';
16+
17+
jest.mock('sentry/utils/useLocation');
18+
jest.mock('sentry/utils/useNavigate');
19+
jest.mock('sentry/actionCreators/modal');
20+
21+
const mockedUseLocation = jest.mocked(useLocation);
22+
const mockUseNavigate = jest.mocked(useNavigate);
23+
const mockOpenSaveQueryModal = jest.mocked(modal.openSaveQueryModal);
24+
25+
describe('useSaveAsMetricItems', () => {
26+
const organization = OrganizationFixture({
27+
features: ['tracemetrics-enabled', 'tracemetrics-saved-queries'],
28+
});
29+
const project = ProjectFixture({id: '1'});
30+
const queryClient = makeTestQueryClient();
31+
ProjectsStore.loadInitialData([project]);
32+
33+
function createWrapper() {
34+
return function ({children}: {children?: React.ReactNode}) {
35+
return (
36+
<OrganizationContext.Provider value={organization}>
37+
<QueryClientProvider client={queryClient}>
38+
<MockMetricQueryParamsContext>{children}</MockMetricQueryParamsContext>
39+
</QueryClientProvider>
40+
</OrganizationContext.Provider>
41+
);
42+
};
43+
}
44+
45+
beforeEach(() => {
46+
jest.resetAllMocks();
47+
MockApiClient.clearMockResponses();
48+
queryClient.clear();
49+
50+
mockedUseLocation.mockReturnValue(
51+
LocationFixture({
52+
query: {
53+
interval: '5m',
54+
},
55+
})
56+
);
57+
mockUseNavigate.mockReturnValue(jest.fn());
58+
59+
MockApiClient.addMockResponse({
60+
url: `/organizations/${organization.slug}/explore/saved/`,
61+
method: 'POST',
62+
body: {id: 'new-query-id', name: 'Test Query'},
63+
});
64+
});
65+
66+
it('should open save query modal when save as new query is clicked', () => {
67+
const {result} = renderHook(
68+
() =>
69+
useSaveAsMetricItems({
70+
interval: '5m',
71+
}),
72+
{wrapper: createWrapper()}
73+
);
74+
75+
const saveAsItems = result.current;
76+
const saveAsQuery = saveAsItems.find(item => item.key === 'save-query') as {
77+
onAction: () => void;
78+
};
79+
80+
saveAsQuery?.onAction?.();
81+
82+
expect(mockOpenSaveQueryModal).toHaveBeenCalledWith({
83+
organization,
84+
saveQuery: expect.any(Function),
85+
source: 'table',
86+
traceItemDataset: 'tracemetrics',
87+
});
88+
});
89+
90+
it('should show both existing and new query options when saved query exists', async () => {
91+
MockApiClient.addMockResponse({
92+
url: `/organizations/${organization.slug}/explore/saved/test-query-id/`,
93+
body: {
94+
id: 'test-query-id',
95+
name: 'Test Metrics Query',
96+
isPrebuilt: false,
97+
query: [{}],
98+
dateAdded: '2024-01-01T00:00:00.000Z',
99+
dateUpdated: '2024-01-01T00:00:00.000Z',
100+
interval: '5m',
101+
lastVisited: '2024-01-01T00:00:00.000Z',
102+
position: null,
103+
projects: [1],
104+
dataset: 'tracemetrics',
105+
starred: false,
106+
},
107+
});
108+
109+
mockedUseLocation.mockReturnValue(
110+
LocationFixture({
111+
query: {
112+
id: 'test-query-id',
113+
interval: '5m',
114+
},
115+
})
116+
);
117+
118+
const {result} = renderHook(
119+
() =>
120+
useSaveAsMetricItems({
121+
interval: '5m',
122+
}),
123+
{wrapper: createWrapper()}
124+
);
125+
126+
await waitFor(() => {
127+
expect(result.current.some(item => item.key === 'update-query')).toBe(true);
128+
});
129+
130+
const saveAsItems = result.current;
131+
expect(saveAsItems.some(item => item.key === 'save-query')).toBe(true);
132+
});
133+
134+
it('should show only new query option when no saved query exists', () => {
135+
mockedUseLocation.mockReturnValue(
136+
LocationFixture({
137+
query: {
138+
interval: '5m',
139+
},
140+
})
141+
);
142+
143+
const {result} = renderHook(
144+
() =>
145+
useSaveAsMetricItems({
146+
interval: '5m',
147+
}),
148+
{wrapper: createWrapper()}
149+
);
150+
151+
const saveAsItems = result.current;
152+
153+
expect(saveAsItems.some(item => item.key === 'update-query')).toBe(false);
154+
expect(saveAsItems.some(item => item.key === 'save-query')).toBe(true);
155+
});
156+
157+
it('should return empty array when metrics saved queries UI is not enabled', () => {
158+
const orgWithoutFeature = OrganizationFixture({
159+
features: [],
160+
});
161+
162+
const {result} = renderHook(
163+
() =>
164+
useSaveAsMetricItems({
165+
interval: '5m',
166+
}),
167+
{
168+
wrapper: function ({children}: {children?: React.ReactNode}) {
169+
return (
170+
<OrganizationContext.Provider value={orgWithoutFeature}>
171+
<QueryClientProvider client={queryClient}>
172+
<MockMetricQueryParamsContext>{children}</MockMetricQueryParamsContext>
173+
</QueryClientProvider>
174+
</OrganizationContext.Provider>
175+
);
176+
},
177+
}
178+
);
179+
180+
const saveAsItems = result.current;
181+
expect(saveAsItems).toEqual([]);
182+
});
183+
});

static/app/views/explore/metrics/useSaveAsMetricItems.tsx

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
addSuccessMessage,
88
} from 'sentry/actionCreators/indicator';
99
import {openSaveQueryModal} from 'sentry/actionCreators/modal';
10-
import type {MenuItemProps} from 'sentry/components/dropdownMenu';
1110
import {t} from 'sentry/locale';
1211
import {defined} from 'sentry/utils';
1312
import {trackAnalytics} from 'sentry/utils/analytics';
@@ -31,12 +30,15 @@ export function useSaveAsMetricItems(_options: UseSaveAsMetricItemsOptions) {
3130
const id = getIdFromLocation(location);
3231
const {data: savedQuery} = useGetSavedQuery(id);
3332

34-
const saveAsQuery = useMemo(() => {
33+
const saveAsItems = useMemo(() => {
3534
if (!canUseMetricsSavedQueriesUI(organization)) {
36-
return null;
35+
return [];
3736
}
37+
38+
const items = [];
39+
3840
if (defined(id) && savedQuery?.isPrebuilt === false) {
39-
return {
41+
items.push({
4042
key: 'update-query',
4143
textValue: t('Existing Query'),
4244
label: <span>{t('Existing Query')}</span>,
@@ -55,10 +57,10 @@ export function useSaveAsMetricItems(_options: UseSaveAsMetricItemsOptions) {
5557
Sentry.captureException(error);
5658
}
5759
},
58-
};
60+
});
5961
}
6062

61-
return {
63+
items.push({
6264
key: 'save-query',
6365
label: <span>{t('A New Query')}</span>,
6466
textValue: t('A New Query'),
@@ -76,14 +78,16 @@ export function useSaveAsMetricItems(_options: UseSaveAsMetricItemsOptions) {
7678
traceItemDataset: TraceItemDataset.TRACEMETRICS,
7779
});
7880
},
79-
};
81+
});
82+
83+
return items;
8084
}, [id, savedQuery?.isPrebuilt, updateQuery, saveQuery, organization]);
8185

8286
// TODO: Implement alert functionality when organizations:tracemetrics-alerts flag is enabled
8387

8488
// TODO: Implement dashboard functionality when organizations:tracemetrics-dashboards flag is enabled
8589

8690
return useMemo(() => {
87-
return [saveAsQuery].filter(Boolean) as MenuItemProps[];
88-
}, [saveAsQuery]);
91+
return saveAsItems;
92+
}, [saveAsItems]);
8993
}

0 commit comments

Comments
 (0)