Skip to content

Commit 45bff19

Browse files
Adds save query ui to explore
1 parent 79c646d commit 45bff19

File tree

11 files changed

+567
-2
lines changed

11 files changed

+567
-2
lines changed

static/app/actionCreators/modal.tsx

+8
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {ModalTypes} from 'sentry/components/globalModal';
44
import type {CreateNewIntegrationModalOptions} from 'sentry/components/modals/createNewIntegrationModal';
55
import type {CreateReleaseIntegrationModalOptions} from 'sentry/components/modals/createReleaseIntegrationModal';
66
import type {DashboardWidgetQuerySelectorModalOptions} from 'sentry/components/modals/dashboardWidgetQuerySelectorModal';
7+
import type {SaveQueryModalProps} from 'sentry/components/modals/explore/saveQueryModal';
78
import type {ImportDashboardFromFileModalProps} from 'sentry/components/modals/importDashboardFromFileModal';
89
import type {InsightChartModalOptions} from 'sentry/components/modals/insightChartModal';
910
import type {InviteRow} from 'sentry/components/modals/inviteMembersModal/types';
@@ -398,3 +399,10 @@ export async function openAddTempestCredentialsModal(options: {
398399

399400
openModal(deps => <Modal {...deps} {...options} />);
400401
}
402+
403+
export async function openSaveQueryModal(options: SaveQueryModalProps) {
404+
const mod = await import('sentry/components/modals/explore/saveQueryModal');
405+
const {default: Modal} = mod;
406+
407+
openModal(deps => <Modal {...deps} {...options} />);
408+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {initializeOrg} from 'sentry-test/initializeOrg';
2+
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
3+
4+
import type {ModalRenderProps} from 'sentry/actionCreators/modal';
5+
import SaveQueryModal from 'sentry/components/modals/explore/saveQueryModal';
6+
7+
const stubEl = (props: {children?: React.ReactNode}) => <div>{props.children}</div>;
8+
9+
describe('SaveQueryModal', function () {
10+
let initialData!: ReturnType<typeof initializeOrg>;
11+
12+
beforeEach(() => {
13+
initialData = initializeOrg();
14+
});
15+
16+
it('should render', function () {
17+
const saveQuery = jest.fn();
18+
render(
19+
<SaveQueryModal
20+
Header={stubEl}
21+
Footer={stubEl as ModalRenderProps['Footer']}
22+
Body={stubEl as ModalRenderProps['Body']}
23+
CloseButton={stubEl}
24+
closeModal={() => {}}
25+
organization={initialData.organization}
26+
query={'span.op:pageload'}
27+
visualizes={[
28+
{
29+
chartType: 1,
30+
yAxes: ['avg(span.duration)'],
31+
label: 'A',
32+
},
33+
]}
34+
groupBys={['span.op']}
35+
saveQuery={saveQuery}
36+
/>
37+
);
38+
39+
expect(screen.getByText('Create a New Query')).toBeInTheDocument();
40+
expect(screen.getByText('Cancel')).toBeInTheDocument();
41+
expect(screen.getByText('Filter')).toBeInTheDocument();
42+
expect(screen.getByText('pageload')).toBeInTheDocument();
43+
expect(screen.getByText('Visualize')).toBeInTheDocument();
44+
expect(screen.getByText('avg(span.duration)')).toBeInTheDocument();
45+
expect(screen.getByText('Group By')).toBeInTheDocument();
46+
expect(screen.getAllByText('span.op')).toHaveLength(2);
47+
});
48+
49+
it('should call saveQuery', async function () {
50+
const saveQuery = jest.fn();
51+
render(
52+
<SaveQueryModal
53+
Header={stubEl}
54+
Footer={stubEl as ModalRenderProps['Footer']}
55+
Body={stubEl as ModalRenderProps['Body']}
56+
CloseButton={stubEl}
57+
closeModal={() => {}}
58+
organization={initialData.organization}
59+
query={'span.op:pageload'}
60+
visualizes={[
61+
{
62+
chartType: 1,
63+
yAxes: ['avg(span.duration)'],
64+
label: 'A',
65+
},
66+
]}
67+
groupBys={[]}
68+
saveQuery={saveQuery}
69+
/>
70+
);
71+
72+
await userEvent.type(screen.getByTitle('Enter a name for your saved query'), 'test');
73+
74+
await userEvent.click(screen.getByLabelText('Create a New Query'));
75+
76+
await waitFor(() => expect(saveQuery).toHaveBeenCalled());
77+
});
78+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import {Fragment, useCallback, useState} from 'react';
2+
import {css} from '@emotion/react';
3+
import styled from '@emotion/styled';
4+
import * as Sentry from '@sentry/react';
5+
6+
import {
7+
addErrorMessage,
8+
addLoadingMessage,
9+
addSuccessMessage,
10+
} from 'sentry/actionCreators/indicator';
11+
import type {ModalRenderProps} from 'sentry/actionCreators/modal';
12+
import ButtonBar from 'sentry/components/buttonBar';
13+
import {Button} from 'sentry/components/core/button';
14+
import {Input} from 'sentry/components/core/input';
15+
import {FormattedQuery} from 'sentry/components/searchQueryBuilder/formattedQuery';
16+
import {t} from 'sentry/locale';
17+
import {space} from 'sentry/styles/space';
18+
import type {Organization, SavedQuery} from 'sentry/types/organization';
19+
import {trackAnalytics} from 'sentry/utils/analytics';
20+
import useOrganization from 'sentry/utils/useOrganization';
21+
import {useSetExplorePageParams} from 'sentry/views/explore/contexts/pageParamsContext';
22+
import type {Visualize} from 'sentry/views/explore/contexts/pageParamsContext/visualizes';
23+
24+
export type SaveQueryModalProps = {
25+
organization: Organization;
26+
query: string;
27+
saveQuery: (name: string) => Promise<SavedQuery>;
28+
visualizes: Visualize[];
29+
groupBys?: string[]; // This needs to be passed in because saveQuery relies on being within the Explore PageParamsContext to fetch params
30+
};
31+
32+
type Props = ModalRenderProps & SaveQueryModalProps;
33+
34+
function SaveQueryModal({
35+
Header,
36+
Body,
37+
Footer,
38+
closeModal,
39+
groupBys,
40+
query,
41+
visualizes,
42+
saveQuery,
43+
}: Props) {
44+
const yAxes = visualizes.flatMap(visualize => visualize.yAxes);
45+
46+
const organization = useOrganization();
47+
48+
const [name, setName] = useState('');
49+
const [isSaving, setIsSaving] = useState(false);
50+
51+
const setExplorePageParams = useSetExplorePageParams();
52+
53+
const updatePageIdAndTitle = useCallback(
54+
(id: string, title: string) => {
55+
setExplorePageParams({id, title});
56+
},
57+
[setExplorePageParams]
58+
);
59+
60+
const onSave = useCallback(async () => {
61+
try {
62+
setIsSaving(true);
63+
addLoadingMessage(t('Saving query...'));
64+
const {id} = await saveQuery(name);
65+
updatePageIdAndTitle(id, name);
66+
addSuccessMessage(t('Query saved successfully'));
67+
trackAnalytics('trace_explorer.save_as', {
68+
save_type: 'saved_query',
69+
ui_source: 'toolbar',
70+
organization,
71+
});
72+
closeModal();
73+
} catch (error) {
74+
addErrorMessage(t('Failed to save query'));
75+
Sentry.captureException(error);
76+
} finally {
77+
setIsSaving(false);
78+
}
79+
}, [saveQuery, name, updatePageIdAndTitle, closeModal, organization]);
80+
81+
return (
82+
<Fragment>
83+
<Header closeButton>
84+
<h4>{t('New Query')}</h4>
85+
</Header>
86+
<Body>
87+
<Wrapper>
88+
<SectionHeader>{t('Name')}</SectionHeader>
89+
<Input
90+
placeholder={t('Enter a name for your saved query')}
91+
onChange={e => setName(e.target.value)}
92+
value={name}
93+
title={t('Enter a name for your saved query')}
94+
/>
95+
</Wrapper>
96+
<Wrapper>
97+
<SectionHeader>{t('Query')}</SectionHeader>
98+
<ExploreParamsContainer>
99+
<ExploreParamSection>
100+
<ExploreParamTitle>{t('Visualize')}</ExploreParamTitle>
101+
<ExploreVisualizes>{yAxes.join(', ')}</ExploreVisualizes>
102+
</ExploreParamSection>
103+
{query && (
104+
<ExploreParamSection>
105+
<ExploreParamTitle>{t('Filter')}</ExploreParamTitle>
106+
<FormattedQueryWrapper>
107+
<FormattedQuery query={query} />
108+
</FormattedQueryWrapper>
109+
</ExploreParamSection>
110+
)}
111+
{groupBys && groupBys.length > 0 && (
112+
<ExploreParamSection>
113+
<ExploreParamTitle>{t('Group By')}</ExploreParamTitle>
114+
<ExploreGroupBys>{groupBys.join(', ')}</ExploreGroupBys>
115+
</ExploreParamSection>
116+
)}
117+
<ExploreParamSection>...</ExploreParamSection>
118+
</ExploreParamsContainer>
119+
</Wrapper>
120+
</Body>
121+
122+
<Footer>
123+
<StyledButtonBar gap={1.5}>
124+
<Button onClick={closeModal} disabled={isSaving}>
125+
{t('Cancel')}
126+
</Button>
127+
<Button onClick={onSave} disabled={!name || isSaving} priority="primary">
128+
{t('Create a New Query')}
129+
</Button>
130+
</StyledButtonBar>
131+
</Footer>
132+
</Fragment>
133+
);
134+
}
135+
136+
export default SaveQueryModal;
137+
138+
const Wrapper = styled('div')`
139+
margin-bottom: ${space(2)};
140+
`;
141+
142+
const StyledButtonBar = styled(ButtonBar)`
143+
@media (max-width: ${props => props.theme.breakpoints.small}) {
144+
grid-template-rows: repeat(2, 1fr);
145+
gap: ${space(1.5)};
146+
width: 100%;
147+
148+
> button {
149+
width: 100%;
150+
}
151+
}
152+
`;
153+
154+
export const modalCss = css`
155+
max-width: 700px;
156+
margin: 70px auto;
157+
`;
158+
159+
const SectionHeader = styled('h6')`
160+
font-size: ${p => p.theme.form.md.fontSize};
161+
margin-bottom: ${space(0.5)};
162+
`;
163+
164+
const ExploreParamsContainer = styled('span')`
165+
display: flex;
166+
flex-direction: row;
167+
gap: ${space(1)};
168+
flex-wrap: wrap;
169+
`;
170+
171+
const ExploreParamSection = styled('span')`
172+
display: flex;
173+
flex-direction: row;
174+
gap: ${space(0.5)};
175+
align-items: center;
176+
`;
177+
178+
const ExploreParamTitle = styled('span')`
179+
font-size: ${p => p.theme.form.sm.fontSize};
180+
color: ${p => p.theme.gray300};
181+
`;
182+
183+
const ExploreVisualizes = styled('span')`
184+
font-size: ${p => p.theme.form.sm.fontSize};
185+
display: flex;
186+
align-items: center;
187+
gap: ${space(0.5)};
188+
background: ${p => p.theme.background};
189+
padding: ${space(0.25)} ${space(0.5)};
190+
border: 1px solid ${p => p.theme.innerBorder};
191+
border-radius: ${p => p.theme.borderRadius};
192+
height: 24px;
193+
white-space: nowrap;
194+
overflow: hidden;
195+
`;
196+
197+
const ExploreGroupBys = ExploreVisualizes;
198+
199+
const FormattedQueryWrapper = styled('span')`
200+
display: inline-block;
201+
`;

static/app/utils/analytics/tracingEventMap.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export type TracingEventParameters = {
130130
};
131131
'trace_explorer.remove_span_condition': Record<string, unknown>;
132132
'trace_explorer.save_as': {
133-
save_type: 'alert' | 'dashboard';
133+
save_type: 'alert' | 'dashboard' | 'saved_query' | 'update_query';
134134
ui_source: 'toolbar' | 'chart' | 'compare chart';
135135
};
136136
'trace_explorer.search_failure': {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type {Crumb} from 'sentry/components/breadcrumbs';
2+
import Breadcrumbs from 'sentry/components/breadcrumbs';
3+
import {t} from 'sentry/locale';
4+
import useOrganization from 'sentry/utils/useOrganization';
5+
6+
function ExploreBreadcrumb() {
7+
const organization = useOrganization();
8+
const crumbs: Crumb[] = [];
9+
crumbs.push({
10+
to: `/organizations/${organization.slug}/traces/`,
11+
label: t('Traces'),
12+
});
13+
crumbs.push({
14+
label: t('Saved Query'),
15+
});
16+
17+
return <Breadcrumbs crumbs={crumbs} />;
18+
}
19+
20+
export default ExploreBreadcrumb;

static/app/views/explore/content.tsx

+8-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {t} from 'sentry/locale';
1414
import {useLocation} from 'sentry/utils/useLocation';
1515
import {useNavigate} from 'sentry/utils/useNavigate';
1616
import useOrganization from 'sentry/utils/useOrganization';
17+
import ExploreBreadcrumb from 'sentry/views/explore/components/breadcrumb';
18+
import {getTitleFromLocation} from 'sentry/views/explore/contexts/pageParamsContext/title';
1719
import {SpansTabContent} from 'sentry/views/explore/spans/spansTab';
1820
import {limitMaxPickableDays} from 'sentry/views/explore/utils';
1921

@@ -35,14 +37,19 @@ export function ExploreContent() {
3537
});
3638
}, [location, navigate]);
3739

40+
const hasSavedQueries = organization.features.includes('performance-saved-queries');
41+
42+
const title = getTitleFromLocation(location);
43+
3844
return (
3945
<SentryDocumentTitle title={t('Traces')} orgSlug={organization?.slug}>
4046
<PageFiltersContainer maxPickableDays={maxPickableDays}>
4147
<Layout.Page>
4248
<Layout.Header unified={prefersStackedNav}>
4349
<Layout.HeaderContent unified={prefersStackedNav}>
50+
{hasSavedQueries && title ? <ExploreBreadcrumb /> : null}
4451
<Layout.Title>
45-
{t('Traces')}
52+
{hasSavedQueries && title ? title : t('Traces')}
4653
<PageHeadingQuestionTooltip
4754
docsUrl="https://github.com/getsentry/sentry/discussions/81239"
4855
title={t(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type {Location} from 'history';
2+
3+
import {defined} from 'sentry/utils';
4+
import {decodeScalar} from 'sentry/utils/queryString';
5+
6+
export function defaultId(): string | undefined {
7+
return undefined;
8+
}
9+
10+
export function getIdFromLocation(location: Location) {
11+
return decodeScalar(location.query.id);
12+
}
13+
14+
export function updateLocationWithId(location: Location, id: string | null | undefined) {
15+
if (defined(id)) {
16+
location.query.id = id;
17+
} else if (id === null) {
18+
delete location.query.id;
19+
}
20+
}

0 commit comments

Comments
 (0)