Skip to content

Commit fa5230e

Browse files
feat(explore): Adds a saved queries table view (#87444)
Adds an `All Queries` page containing a table view of owned and shared saved explore queries. Guarded behind the `performance-saved-queries` feature flag, and accessible from the sidenav under `Explore`.
1 parent 77cb78a commit fa5230e

File tree

10 files changed

+484
-0
lines changed

10 files changed

+484
-0
lines changed

static/app/components/sidebar/index.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,18 @@ function Sidebar() {
232232
</Feature>
233233
);
234234

235+
const savedQueries = hasOrganization && (
236+
<Feature features="performance-saved-queries" organization={organization}>
237+
<SidebarItem
238+
{...sidebarItemProps}
239+
label={<GuideAnchor target="saved-queries">{t('All Queries')}</GuideAnchor>}
240+
to={`/organizations/${organization?.slug}/explore/saved-queries/`}
241+
id="performance-saved-queries"
242+
icon={<SubitemDot collapsed />}
243+
/>
244+
</Feature>
245+
);
246+
235247
const hasPerfLandingRemovalFlag = organization?.features.includes(
236248
'insights-performance-landing-removal'
237249
);
@@ -426,6 +438,7 @@ function Sidebar() {
426438
{profiling}
427439
{replays}
428440
{discover}
441+
{savedQueries}
429442
</SidebarAccordion>
430443
);
431444

static/app/routes.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2001,6 +2001,10 @@ function buildRoutes() {
20012001
{releasesChildRoutes}
20022002
</Route>
20032003
<Route path="logs/" component={make(() => import('sentry/views/explore/logs'))} />
2004+
<Route
2005+
path="saved-queries/"
2006+
component={make(() => import('sentry/views/explore/savedQueries'))}
2007+
/>
20042008
</Route>
20052009
);
20062010

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {useCallback} from 'react';
2+
3+
import useApi from 'sentry/utils/useApi';
4+
import useOrganization from 'sentry/utils/useOrganization';
5+
6+
export function useDeleteQuery() {
7+
const api = useApi();
8+
const organization = useOrganization();
9+
10+
const deleteQuery = useCallback(
11+
(id: number) => {
12+
api.requestPromise(`/organizations/${organization.slug}/explore/saved/${id}/`, {
13+
method: 'DELETE',
14+
});
15+
},
16+
[api, organization.slug]
17+
);
18+
19+
return {deleteQuery};
20+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type {Actor} from 'sentry/types/core';
2+
import {useApiQuery} from 'sentry/utils/queryClient';
3+
import useOrganization from 'sentry/utils/useOrganization';
4+
5+
// Comes from ExploreSavedQueryModelSerializer
6+
export type SavedQuery = {
7+
createdBy: Actor;
8+
dateAdded: string;
9+
dateUpdated: string;
10+
end: string;
11+
environment: string[];
12+
fields: string[];
13+
id: number;
14+
interval: string;
15+
lastVisited: string;
16+
mode: string;
17+
name: string;
18+
orderby: string;
19+
projects: number[];
20+
query: string;
21+
queryDataset: string;
22+
range: string;
23+
start: string;
24+
// Can probably have stricter type here
25+
visualize: Array<{
26+
chartType: number;
27+
yAxes: string[];
28+
}>;
29+
};
30+
31+
type Props = {
32+
sortBy: string;
33+
exclude?: 'owned' | 'shared';
34+
perPage?: number;
35+
};
36+
37+
export function useGetSavedQueries({sortBy, exclude, perPage = 5}: Props) {
38+
const organization = useOrganization();
39+
const {data, isLoading} = useApiQuery<SavedQuery[]>(
40+
[
41+
`/organizations/${organization.slug}/explore/saved/`,
42+
{query: {sortBy, exclude, per_page: perPage}},
43+
],
44+
{
45+
staleTime: 0,
46+
}
47+
);
48+
49+
return {data, isLoading};
50+
}

static/app/views/explore/navigation.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ export default function ExploreNavigation({children}: Props) {
7575
>
7676
{t('Releases')}
7777
</SecondaryNav.Item>
78+
<Feature features="performance-saved-queries">
79+
<SecondaryNav.Item to={`${baseUrl}/saved-queries/`}>
80+
{t('All Queries')}
81+
</SecondaryNav.Item>
82+
</Feature>
7883
</SecondaryNav.Section>
7984
</SecondaryNav.Body>
8085
</SecondaryNav>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import Breadcrumbs from 'sentry/components/breadcrumbs';
2+
import {FeatureBadge} from 'sentry/components/core/badge/featureBadge';
3+
import * as Layout from 'sentry/components/layouts/thirds';
4+
import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
5+
import {t} from 'sentry/locale';
6+
import useOrganization from 'sentry/utils/useOrganization';
7+
import {SavedQueriesLandingContent} from 'sentry/views/explore/savedQueries/savedQueriesLandingContent';
8+
9+
export default function SavedQueriesView() {
10+
const organization = useOrganization();
11+
12+
return (
13+
<SentryDocumentTitle title={t('All Queries')} orgSlug={organization?.slug}>
14+
<Layout.Page>
15+
<Layout.Header>
16+
<Layout.HeaderContent>
17+
<Breadcrumbs
18+
crumbs={[
19+
{
20+
label: t('Explore'),
21+
to: `/organizations/${organization.slug}/traces/`,
22+
},
23+
{
24+
label: t('All Queries'),
25+
to: `/organizations/${organization.slug}/explore/saved-queries/`,
26+
},
27+
]}
28+
/>
29+
<Layout.Title>
30+
{t('All Queries')}
31+
<FeatureBadge type="alpha" />
32+
</Layout.Title>
33+
</Layout.HeaderContent>
34+
</Layout.Header>
35+
<Layout.Body>
36+
<Layout.Main fullWidth>
37+
<SavedQueriesLandingContent />
38+
</Layout.Main>
39+
</Layout.Body>
40+
</Layout.Page>
41+
</SentryDocumentTitle>
42+
);
43+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {t} from 'sentry/locale';
2+
3+
import {SavedQueriesTable} from './savedQueriesTable';
4+
5+
export function SavedQueriesLandingContent() {
6+
return (
7+
<div>
8+
<h4>{t('Owned by Me')}</h4>
9+
<SavedQueriesTable mode="owned" />
10+
<h4>{t('Shared with Me')}</h4>
11+
<SavedQueriesTable mode="shared" perPage={8} />
12+
</div>
13+
);
14+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {initializeOrg} from 'sentry-test/initializeOrg';
2+
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
3+
4+
import {SavedQueriesTable} from 'sentry/views/explore/savedQueries/savedQueriesTable';
5+
6+
describe('SavedQueriesTable', () => {
7+
const {organization} = initializeOrg();
8+
let getQueriesMock: jest.Mock;
9+
let deleteQueryMock: jest.Mock;
10+
11+
beforeEach(() => {
12+
getQueriesMock = MockApiClient.addMockResponse({
13+
url: `/organizations/${organization.slug}/explore/saved/`,
14+
body: [
15+
{
16+
id: 1,
17+
name: 'Query Name',
18+
visualize: [],
19+
projects: [1],
20+
createdBy: {
21+
name: 'Test User',
22+
},
23+
},
24+
],
25+
});
26+
deleteQueryMock = MockApiClient.addMockResponse({
27+
url: `/organizations/${organization.slug}/explore/saved/1/`,
28+
method: 'DELETE',
29+
});
30+
});
31+
32+
afterEach(() => {
33+
MockApiClient.clearMockResponses();
34+
});
35+
36+
it('should render', async () => {
37+
render(<SavedQueriesTable mode="owned" />);
38+
expect(screen.getByText('Name')).toBeInTheDocument();
39+
expect(screen.getByText('Projects')).toBeInTheDocument();
40+
expect(screen.getByText('Query')).toBeInTheDocument();
41+
expect(screen.getByText('Owner')).toBeInTheDocument();
42+
expect(screen.getByText('Access')).toBeInTheDocument();
43+
expect(screen.getByText('Last Viewed')).toBeInTheDocument();
44+
await screen.findByText('Query Name');
45+
});
46+
47+
it('should request for owned queries', async () => {
48+
render(<SavedQueriesTable mode="owned" />);
49+
await waitFor(() =>
50+
expect(getQueriesMock).toHaveBeenCalledWith(
51+
`/organizations/${organization.slug}/explore/saved/`,
52+
expect.objectContaining({
53+
method: 'GET',
54+
query: expect.objectContaining({sortBy: 'mostPopular', exclude: 'shared'}),
55+
})
56+
)
57+
);
58+
});
59+
60+
it('should request for shared queries', async () => {
61+
render(<SavedQueriesTable mode="shared" />);
62+
await waitFor(() =>
63+
expect(getQueriesMock).toHaveBeenCalledWith(
64+
`/organizations/${organization.slug}/explore/saved/`,
65+
expect.objectContaining({
66+
method: 'GET',
67+
query: expect.objectContaining({
68+
sortBy: 'mostPopular',
69+
exclude: 'owned',
70+
}),
71+
})
72+
)
73+
);
74+
});
75+
76+
it('deletes a query', async () => {
77+
render(<SavedQueriesTable mode="owned" />);
78+
await screen.findByText('Query Name');
79+
await userEvent.click(screen.getByLabelText('Query actions'));
80+
await userEvent.click(screen.getByText('Delete'));
81+
await waitFor(() =>
82+
expect(deleteQueryMock).toHaveBeenCalledWith(
83+
`/organizations/${organization.slug}/explore/saved/1/`,
84+
expect.objectContaining({
85+
method: 'DELETE',
86+
})
87+
)
88+
);
89+
});
90+
});

0 commit comments

Comments
 (0)