Skip to content

Commit f8f4908

Browse files
Zylphrexbillyvg
authored andcommitted
feat(multi-query): Add ability to duplicate queries (#93513)
Adds a new option to duplicate a query. This also means we moved the delete button into a menu along side the duplicate button. Closes EXP-303
1 parent f84d9b8 commit f8f4908

File tree

4 files changed

+251
-31
lines changed

4 files changed

+251
-31
lines changed

static/app/views/explore/multiQueryMode/content.spec.tsx

Lines changed: 185 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -641,7 +641,58 @@ describe('MultiQueryModeContent', function () {
641641
]);
642642
});
643643

644-
it('updates query at the correct index', async function () {
644+
it('allows changing a query', async function () {
645+
let queries: any;
646+
function Component() {
647+
queries = useReadQueriesFromLocation();
648+
return <MultiQueryModeContent />;
649+
}
650+
651+
render(
652+
<PageParamsProvider>
653+
<SpanTagsProvider dataset={DiscoverDatasets.SPANS_EAP} enabled>
654+
<Component />
655+
</SpanTagsProvider>
656+
</PageParamsProvider>
657+
);
658+
659+
expect(queries).toEqual([
660+
{
661+
yAxes: ['count(span.duration)'],
662+
sortBys: [
663+
{
664+
field: 'timestamp',
665+
kind: 'desc',
666+
},
667+
],
668+
fields: ['id', 'span.duration', 'timestamp'],
669+
groupBys: [],
670+
query: '',
671+
},
672+
]);
673+
674+
const section = screen.getByTestId('section-visualize-0');
675+
await userEvent.click(within(section).getByRole('button', {name: 'count'}));
676+
await userEvent.click(within(section).getByRole('option', {name: 'avg'}));
677+
await userEvent.click(within(section).getByRole('button', {name: 'span.duration'}));
678+
await userEvent.click(within(section).getByRole('option', {name: 'span.self_time'}));
679+
expect(queries).toEqual([
680+
{
681+
yAxes: ['avg(span.self_time)'],
682+
sortBys: [
683+
{
684+
field: 'timestamp',
685+
kind: 'desc',
686+
},
687+
],
688+
fields: ['id', 'span.self_time', 'timestamp'],
689+
groupBys: [],
690+
query: '',
691+
},
692+
]);
693+
});
694+
695+
it('allows adding a query', async function () {
645696
let queries: any;
646697
function Component() {
647698
queries = useReadQueriesFromLocation();
@@ -699,12 +750,47 @@ describe('MultiQueryModeContent', function () {
699750
query: '',
700751
},
701752
]);
753+
});
754+
755+
it('allows duplicating a query', async function () {
756+
let queries: any;
757+
function Component() {
758+
queries = useReadQueriesFromLocation();
759+
return <MultiQueryModeContent />;
760+
}
761+
762+
render(
763+
<PageParamsProvider>
764+
<SpanTagsProvider dataset={DiscoverDatasets.SPANS_EAP} enabled>
765+
<Component />
766+
</SpanTagsProvider>
767+
</PageParamsProvider>
768+
);
769+
770+
expect(queries).toEqual([
771+
{
772+
yAxes: ['count(span.duration)'],
773+
sortBys: [
774+
{
775+
field: 'timestamp',
776+
kind: 'desc',
777+
},
778+
],
779+
fields: ['id', 'span.duration', 'timestamp'],
780+
groupBys: [],
781+
query: '',
782+
},
783+
]);
702784

703785
const section = screen.getByTestId('section-visualize-0');
704786
await userEvent.click(within(section).getByRole('button', {name: 'count'}));
705787
await userEvent.click(within(section).getByRole('option', {name: 'avg'}));
706788
await userEvent.click(within(section).getByRole('button', {name: 'span.duration'}));
707789
await userEvent.click(within(section).getByRole('option', {name: 'span.self_time'}));
790+
791+
// Duplicate chart
792+
await userEvent.click(screen.getByRole('button', {name: 'More options'}));
793+
await userEvent.click(screen.getByRole('menuitemradio', {name: 'Duplicate Query'}));
708794
expect(queries).toEqual([
709795
{
710796
yAxes: ['avg(span.self_time)'],
@@ -718,6 +804,37 @@ describe('MultiQueryModeContent', function () {
718804
groupBys: [],
719805
query: '',
720806
},
807+
{
808+
yAxes: ['avg(span.self_time)'],
809+
sortBys: [
810+
{
811+
field: 'timestamp',
812+
kind: 'desc',
813+
},
814+
],
815+
fields: ['id', 'span.self_time', 'timestamp'],
816+
groupBys: [],
817+
query: '',
818+
},
819+
]);
820+
});
821+
822+
it('allows deleting a query', async function () {
823+
let queries: any;
824+
function Component() {
825+
queries = useReadQueriesFromLocation();
826+
return <MultiQueryModeContent />;
827+
}
828+
829+
render(
830+
<PageParamsProvider>
831+
<SpanTagsProvider dataset={DiscoverDatasets.SPANS_EAP} enabled>
832+
<Component />
833+
</SpanTagsProvider>
834+
</PageParamsProvider>
835+
);
836+
837+
expect(queries).toEqual([
721838
{
722839
yAxes: ['count(span.duration)'],
723840
sortBys: [
@@ -731,7 +848,71 @@ describe('MultiQueryModeContent', function () {
731848
query: '',
732849
},
733850
]);
734-
await userEvent.click(screen.getAllByLabelText('Delete Query')[0]!);
851+
852+
// Add chart
853+
await userEvent.click(screen.getByRole('button', {name: 'Add Query'}));
854+
expect(queries).toEqual([
855+
{
856+
yAxes: ['count(span.duration)'],
857+
sortBys: [
858+
{
859+
field: 'timestamp',
860+
kind: 'desc',
861+
},
862+
],
863+
fields: ['id', 'span.duration', 'timestamp'],
864+
groupBys: [],
865+
query: '',
866+
},
867+
{
868+
yAxes: ['count(span.duration)'],
869+
sortBys: [
870+
{
871+
field: 'timestamp',
872+
kind: 'desc',
873+
},
874+
],
875+
fields: ['id', 'span.duration', 'timestamp'],
876+
groupBys: [],
877+
query: '',
878+
},
879+
]);
880+
881+
const section = screen.getByTestId('section-visualize-0');
882+
await userEvent.click(within(section).getByRole('button', {name: 'count'}));
883+
await userEvent.click(within(section).getByRole('option', {name: 'avg'}));
884+
await userEvent.click(within(section).getByRole('button', {name: 'span.duration'}));
885+
await userEvent.click(within(section).getByRole('option', {name: 'span.self_time'}));
886+
expect(queries).toEqual([
887+
{
888+
yAxes: ['avg(span.self_time)'],
889+
sortBys: [
890+
{
891+
field: 'timestamp',
892+
kind: 'desc',
893+
},
894+
],
895+
fields: ['id', 'span.self_time', 'timestamp'],
896+
groupBys: [],
897+
query: '',
898+
},
899+
{
900+
yAxes: ['count(span.duration)'],
901+
sortBys: [
902+
{
903+
field: 'timestamp',
904+
kind: 'desc',
905+
},
906+
],
907+
fields: ['id', 'span.duration', 'timestamp'],
908+
groupBys: [],
909+
query: '',
910+
},
911+
]);
912+
913+
await userEvent.click(screen.getAllByRole('button', {name: 'More options'})[0]!);
914+
await userEvent.click(screen.getByRole('menuitemradio', {name: 'Delete Query'}));
915+
735916
expect(queries).toEqual([
736917
{
737918
yAxes: ['count(span.duration)'],
@@ -960,14 +1141,11 @@ describe('MultiQueryModeContent', function () {
9601141
},
9611142
},
9621143
});
963-
function Component() {
964-
return <MultiQueryModeContent />;
965-
}
9661144

9671145
render(
9681146
<PageParamsProvider>
9691147
<SpanTagsProvider dataset={DiscoverDatasets.SPANS_EAP} enabled>
970-
<Component />
1148+
<MultiQueryModeContent />
9711149
</SpanTagsProvider>
9721150
</PageParamsProvider>,
9731151
{
@@ -1046,14 +1224,10 @@ describe('MultiQueryModeContent', function () {
10461224
},
10471225
});
10481226

1049-
function Component() {
1050-
return <MultiQueryModeContent />;
1051-
}
1052-
10531227
render(
10541228
<PageParamsProvider>
10551229
<SpanTagsProvider dataset={DiscoverDatasets.SPANS_EAP} enabled>
1056-
<Component />
1230+
<MultiQueryModeContent />
10571231
</SpanTagsProvider>
10581232
</PageParamsProvider>,
10591233
{

static/app/views/explore/multiQueryMode/locationUtils.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,25 @@ export function useDeleteQueryAtIndex() {
212212
);
213213
}
214214

215+
export function useDuplicateQueryAtIndex() {
216+
const location = useLocation();
217+
const queries = useReadQueriesFromLocation();
218+
const navigate = useNavigate();
219+
220+
return useCallback(
221+
(index: number) => {
222+
const query = queries[index];
223+
if (defined(query)) {
224+
const duplicate = structuredClone(query);
225+
const newQueries = queries.toSpliced(index + 1, 0, duplicate);
226+
const target = getUpdatedLocationWithQueries(location, newQueries);
227+
navigate(target);
228+
}
229+
},
230+
[location, navigate, queries]
231+
);
232+
}
233+
215234
export function getSamplesTargetAtIndex(
216235
index: number,
217236
queries: ReadableExploreQueryParts[],
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {Button} from 'sentry/components/core/button';
2+
import {DropdownMenu} from 'sentry/components/dropdownMenu';
3+
import {IconEllipsis} from 'sentry/icons/iconEllipsis';
4+
import {t} from 'sentry/locale';
5+
import {
6+
useDeleteQueryAtIndex,
7+
useDuplicateQueryAtIndex,
8+
} from 'sentry/views/explore/multiQueryMode/locationUtils';
9+
10+
type Props = {
11+
index: number;
12+
totalQueryRows: number;
13+
};
14+
15+
export function MenuSection({index, totalQueryRows}: Props) {
16+
const deleteQuery = useDeleteQueryAtIndex();
17+
const duplicateQuery = useDuplicateQueryAtIndex();
18+
19+
return (
20+
<DropdownMenu
21+
items={[
22+
{
23+
key: 'delete-query',
24+
label: t('Delete Query'),
25+
onAction: () => deleteQuery(index),
26+
disabled: totalQueryRows === 1,
27+
},
28+
{
29+
key: 'duplicate-query',
30+
label: t('Duplicate Query'),
31+
onAction: () => duplicateQuery(index),
32+
},
33+
]}
34+
trigger={triggerProps => (
35+
<Button
36+
{...triggerProps}
37+
aria-label={t('More options')}
38+
icon={<IconEllipsis size="xs" />}
39+
/>
40+
)}
41+
/>
42+
);
43+
}

static/app/views/explore/multiQueryMode/queryRow.tsx

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import {Fragment} from 'react';
22
import styled from '@emotion/styled';
33

4-
import {Button} from 'sentry/components/core/button';
54
import {LazyRender} from 'sentry/components/lazyRender';
6-
import {IconDelete} from 'sentry/icons/iconDelete';
7-
import {t} from 'sentry/locale';
85
import {space} from 'sentry/styles/space';
96
import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode';
107
import {useCompareAnalytics} from 'sentry/views/explore/hooks/useAnalytics';
@@ -17,9 +14,9 @@ import {useMultiQueryTimeseries} from 'sentry/views/explore/multiQueryMode/hooks
1714
import {
1815
getQueryMode,
1916
type ReadableExploreQueryParts,
20-
useDeleteQueryAtIndex,
2117
} from 'sentry/views/explore/multiQueryMode/locationUtils';
2218
import {GroupBySection} from 'sentry/views/explore/multiQueryMode/queryConstructors/groupBy';
19+
import {MenuSection} from 'sentry/views/explore/multiQueryMode/queryConstructors/menu';
2320
import {SearchBarSection} from 'sentry/views/explore/multiQueryMode/queryConstructors/search';
2421
import {SortBySection} from 'sentry/views/explore/multiQueryMode/queryConstructors/sortBy';
2522
import {VisualizeSection} from 'sentry/views/explore/multiQueryMode/queryConstructors/visualize';
@@ -33,8 +30,6 @@ type Props = {
3330
};
3431

3532
export function QueryRow({query: queryParts, index, totalQueryRows}: Props) {
36-
const deleteQuery = useDeleteQueryAtIndex();
37-
3833
const {groupBys, query, yAxes, sortBys} = queryParts;
3934
const mode = getQueryMode(groupBys);
4035

@@ -80,14 +75,7 @@ export function QueryRow({query: queryParts, index, totalQueryRows}: Props) {
8075
<VisualizeSection query={queryParts} index={index} />
8176
<GroupBySection query={queryParts} index={index} />
8277
<SortBySection query={queryParts} index={index} />
83-
<DeleteButton
84-
borderless
85-
icon={<IconDelete />}
86-
size="zero"
87-
disabled={totalQueryRows === 1}
88-
onClick={() => deleteQuery(index)}
89-
aria-label={t('Delete Query')}
90-
/>
78+
<MenuSection index={index} totalQueryRows={totalQueryRows} />
9179
</DropDownGrid>
9280
</QueryConstructionSection>
9381
<QueryVisualizationSection data-test-id={`section-visualization-${index}`}>
@@ -125,15 +113,11 @@ const QueryConstructionSection = styled('div')`
125113

126114
const DropDownGrid = styled('div')`
127115
display: grid;
128-
grid-template-columns: repeat(3, minmax(0, auto)) ${space(2)};
129-
align-items: center;
116+
grid-template-columns: repeat(3, minmax(0, auto)) min-content;
117+
align-items: end;
130118
gap: ${space(1)};
131119
`;
132120

133-
const DeleteButton = styled(Button)`
134-
margin-top: ${space(2)};
135-
`;
136-
137121
const QueryVisualizationSection = styled('div')`
138122
display: grid;
139123
grid-template-columns: 2fr 1.2fr;

0 commit comments

Comments
 (0)