Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(explore): Adds save query UI to explore #87301

Merged
merged 5 commits into from
Mar 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions static/app/actionCreators/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {ModalTypes} from 'sentry/components/globalModal';
import type {CreateNewIntegrationModalOptions} from 'sentry/components/modals/createNewIntegrationModal';
import type {CreateReleaseIntegrationModalOptions} from 'sentry/components/modals/createReleaseIntegrationModal';
import type {DashboardWidgetQuerySelectorModalOptions} from 'sentry/components/modals/dashboardWidgetQuerySelectorModal';
import type {SaveQueryModalProps} from 'sentry/components/modals/explore/saveQueryModal';
import type {ImportDashboardFromFileModalProps} from 'sentry/components/modals/importDashboardFromFileModal';
import type {InsightChartModalOptions} from 'sentry/components/modals/insightChartModal';
import type {InviteRow} from 'sentry/components/modals/inviteMembersModal/types';
Expand Down Expand Up @@ -398,3 +399,10 @@ export async function openAddTempestCredentialsModal(options: {

openModal(deps => <Modal {...deps} {...options} />);
}

export async function openSaveQueryModal(options: SaveQueryModalProps) {
const mod = await import('sentry/components/modals/explore/saveQueryModal');
const {default: Modal} = mod;

openModal(deps => <Modal {...deps} {...options} />);
}
78 changes: 78 additions & 0 deletions static/app/components/modals/explore/saveQueryModal.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {initializeOrg} from 'sentry-test/initializeOrg';
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';

import type {ModalRenderProps} from 'sentry/actionCreators/modal';
import SaveQueryModal from 'sentry/components/modals/explore/saveQueryModal';

const stubEl = (props: {children?: React.ReactNode}) => <div>{props.children}</div>;

describe('SaveQueryModal', function () {
let initialData!: ReturnType<typeof initializeOrg>;

beforeEach(() => {
initialData = initializeOrg();
});

it('should render', function () {
const saveQuery = jest.fn();
render(
<SaveQueryModal
Header={stubEl}
Footer={stubEl as ModalRenderProps['Footer']}
Body={stubEl as ModalRenderProps['Body']}
CloseButton={stubEl}
closeModal={() => {}}
organization={initialData.organization}
query={'span.op:pageload'}
visualizes={[
{
chartType: 1,
yAxes: ['avg(span.duration)'],
label: 'A',
},
]}
groupBys={['span.op']}
saveQuery={saveQuery}
/>
);

expect(screen.getByText('Create a New Query')).toBeInTheDocument();
expect(screen.getByText('Cancel')).toBeInTheDocument();
expect(screen.getByText('Filter')).toBeInTheDocument();
expect(screen.getByText('pageload')).toBeInTheDocument();
expect(screen.getByText('Visualize')).toBeInTheDocument();
expect(screen.getByText('avg(span.duration)')).toBeInTheDocument();
expect(screen.getByText('Group By')).toBeInTheDocument();
expect(screen.getAllByText('span.op')).toHaveLength(2);
});

it('should call saveQuery', async function () {
const saveQuery = jest.fn();
render(
<SaveQueryModal
Header={stubEl}
Footer={stubEl as ModalRenderProps['Footer']}
Body={stubEl as ModalRenderProps['Body']}
CloseButton={stubEl}
closeModal={() => {}}
organization={initialData.organization}
query={'span.op:pageload'}
visualizes={[
{
chartType: 1,
yAxes: ['avg(span.duration)'],
label: 'A',
},
]}
groupBys={[]}
saveQuery={saveQuery}
/>
);

await userEvent.type(screen.getByTitle('Enter a name for your saved query'), 'test');

await userEvent.click(screen.getByLabelText('Create a New Query'));

await waitFor(() => expect(saveQuery).toHaveBeenCalled());
});
});
210 changes: 210 additions & 0 deletions static/app/components/modals/explore/saveQueryModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import {Fragment, useCallback, useState} from 'react';
import {css} from '@emotion/react';
import styled from '@emotion/styled';
import * as Sentry from '@sentry/react';

import {
addErrorMessage,
addLoadingMessage,
addSuccessMessage,
} from 'sentry/actionCreators/indicator';
import type {ModalRenderProps} from 'sentry/actionCreators/modal';
import ButtonBar from 'sentry/components/buttonBar';
import {Button} from 'sentry/components/core/button';
import {Input} from 'sentry/components/core/input';
import {FormattedQuery} from 'sentry/components/searchQueryBuilder/formattedQuery';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Organization, SavedQuery} from 'sentry/types/organization';
import {trackAnalytics} from 'sentry/utils/analytics';
import useOrganization from 'sentry/utils/useOrganization';
import {useSetExplorePageParams} from 'sentry/views/explore/contexts/pageParamsContext';
import type {Visualize} from 'sentry/views/explore/contexts/pageParamsContext/visualizes';

export type SaveQueryModalProps = {
organization: Organization;
query: string;
saveQuery: (name: string) => Promise<SavedQuery>;
visualizes: Visualize[];
groupBys?: string[]; // This needs to be passed in because saveQuery relies on being within the Explore PageParamsContext to fetch params
};

type Props = ModalRenderProps & SaveQueryModalProps;

function SaveQueryModal({
Header,
Body,
Footer,
closeModal,
groupBys,
query,
visualizes,
saveQuery,
}: Props) {
const yAxes = visualizes.flatMap(visualize => visualize.yAxes);

const organization = useOrganization();

const [name, setName] = useState('');
const [isSaving, setIsSaving] = useState(false);

const setExplorePageParams = useSetExplorePageParams();

const updatePageIdAndTitle = useCallback(
(id: string, title: string) => {
setExplorePageParams({id, title});
},
[setExplorePageParams]
);

const onSave = useCallback(async () => {
try {
setIsSaving(true);
addLoadingMessage(t('Saving query...'));
const {id} = await saveQuery(name);
updatePageIdAndTitle(id, name);
addSuccessMessage(t('Query saved successfully'));
trackAnalytics('trace_explorer.save_as', {
save_type: 'saved_query',
ui_source: 'toolbar',
organization,
});
closeModal();
} catch (error) {
addErrorMessage(t('Failed to save query'));
Sentry.captureException(error);
} finally {
setIsSaving(false);
}
}, [saveQuery, name, updatePageIdAndTitle, closeModal, organization]);

return (
<Fragment>
<Header closeButton>
<h4>{t('New Query')}</h4>
</Header>
<Body>
<Wrapper>
<SectionHeader>{t('Name')}</SectionHeader>
<Input
placeholder={t('Enter a name for your saved query')}
onChange={e => setName(e.target.value)}
value={name}
title={t('Enter a name for your saved query')}
/>
</Wrapper>
<Wrapper>
<SectionHeader>{t('Query')}</SectionHeader>
<ExploreParamsContainer>
<ExploreParamSection>
<ExploreParamTitle>{t('Visualize')}</ExploreParamTitle>
<ExploreParamSection>
{yAxes.map(yAxis => (
<ExploreVisualizes key={yAxis}>{yAxis}</ExploreVisualizes>
))}
</ExploreParamSection>
</ExploreParamSection>
{query && (
<ExploreParamSection>
<ExploreParamTitle>{t('Filter')}</ExploreParamTitle>
<FormattedQueryWrapper>
<FormattedQuery query={query} />
</FormattedQueryWrapper>
</ExploreParamSection>
)}
{groupBys && groupBys.length > 0 && (
<ExploreParamSection>
<ExploreParamTitle>{t('Group By')}</ExploreParamTitle>
<ExploreParamSection>
{groupBys.map(groupBy => (
<ExploreGroupBys key={groupBy}>{groupBy}</ExploreGroupBys>
))}
</ExploreParamSection>
</ExploreParamSection>
)}
<ExploreParamSection>...</ExploreParamSection>
</ExploreParamsContainer>
</Wrapper>
</Body>

<Footer>
<StyledButtonBar gap={1.5}>
<Button onClick={closeModal} disabled={isSaving}>
{t('Cancel')}
</Button>
<Button onClick={onSave} disabled={!name || isSaving} priority="primary">
{t('Create a New Query')}
</Button>
</StyledButtonBar>
</Footer>
</Fragment>
);
}

export default SaveQueryModal;

const Wrapper = styled('div')`
margin-bottom: ${space(2)};
`;

const StyledButtonBar = styled(ButtonBar)`
@media (max-width: ${props => props.theme.breakpoints.small}) {
grid-template-rows: repeat(2, 1fr);
gap: ${space(1.5)};
width: 100%;

> button {
width: 100%;
}
}
`;

export const modalCss = css`
max-width: 700px;
margin: 70px auto;
`;

const SectionHeader = styled('h6')`
font-size: ${p => p.theme.form.md.fontSize};
margin-bottom: ${space(0.5)};
`;

const ExploreParamsContainer = styled('span')`
display: flex;
flex-direction: row;
gap: ${space(1)};
flex-wrap: wrap;
`;

const ExploreParamSection = styled('span')`
display: flex;
flex-direction: row;
gap: ${space(0.5)};
overflow: hidden;
flex-wrap: wrap;
`;

const ExploreParamTitle = styled('span')`
font-size: ${p => p.theme.form.sm.fontSize};
color: ${p => p.theme.gray300};
white-space: nowrap;
padding-top: 3px;
`;

const ExploreVisualizes = styled('span')`
font-size: ${p => p.theme.form.sm.fontSize};
background: ${p => p.theme.background};
padding: ${space(0.25)} ${space(0.5)};
border: 1px solid ${p => p.theme.innerBorder};
border-radius: ${p => p.theme.borderRadius};
height: 24px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
`;

const ExploreGroupBys = ExploreVisualizes;

const FormattedQueryWrapper = styled('span')`
display: inline-block;
`;
2 changes: 1 addition & 1 deletion static/app/utils/analytics/tracingEventMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export type TracingEventParameters = {
};
'trace_explorer.remove_span_condition': Record<string, unknown>;
'trace_explorer.save_as': {
save_type: 'alert' | 'dashboard';
save_type: 'alert' | 'dashboard' | 'saved_query' | 'update_query';
ui_source: 'toolbar' | 'chart' | 'compare chart';
};
'trace_explorer.search_failure': {
Expand Down
20 changes: 20 additions & 0 deletions static/app/views/explore/components/breadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type {Crumb} from 'sentry/components/breadcrumbs';
import Breadcrumbs from 'sentry/components/breadcrumbs';
import {t} from 'sentry/locale';
import useOrganization from 'sentry/utils/useOrganization';

function ExploreBreadcrumb() {
const organization = useOrganization();
const crumbs: Crumb[] = [];
crumbs.push({
to: `/organizations/${organization.slug}/traces/`,
label: t('Traces'),
});
crumbs.push({
label: t('Saved Query'),
});

return <Breadcrumbs crumbs={crumbs} />;
}

export default ExploreBreadcrumb;
9 changes: 8 additions & 1 deletion static/app/views/explore/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {t} from 'sentry/locale';
import {useLocation} from 'sentry/utils/useLocation';
import {useNavigate} from 'sentry/utils/useNavigate';
import useOrganization from 'sentry/utils/useOrganization';
import ExploreBreadcrumb from 'sentry/views/explore/components/breadcrumb';
import {getTitleFromLocation} from 'sentry/views/explore/contexts/pageParamsContext/title';
import {SpansTabContent} from 'sentry/views/explore/spans/spansTab';
import {limitMaxPickableDays} from 'sentry/views/explore/utils';

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

const hasSavedQueries = organization.features.includes('performance-saved-queries');

const title = getTitleFromLocation(location);

return (
<SentryDocumentTitle title={t('Traces')} orgSlug={organization?.slug}>
<PageFiltersContainer maxPickableDays={maxPickableDays}>
<Layout.Page>
<Layout.Header unified={prefersStackedNav}>
<Layout.HeaderContent unified={prefersStackedNav}>
{hasSavedQueries && title ? <ExploreBreadcrumb /> : null}
<Layout.Title>
{t('Traces')}
{hasSavedQueries && title ? title : t('Traces')}
<PageHeadingQuestionTooltip
docsUrl="https://github.com/getsentry/sentry/discussions/81239"
title={t(
Expand Down
20 changes: 20 additions & 0 deletions static/app/views/explore/contexts/pageParamsContext/id.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type {Location} from 'history';

import {defined} from 'sentry/utils';
import {decodeScalar} from 'sentry/utils/queryString';

export function defaultId(): undefined {
return undefined;
}

export function getIdFromLocation(location: Location) {
return decodeScalar(location.query.id);
}

export function updateLocationWithId(location: Location, id: string | null | undefined) {
if (defined(id)) {
location.query.id = id;
} else if (id === null) {
delete location.query.id;
}
}
Loading
Loading