diff --git a/static/app/views/explore/metrics/hooks/useSortableMetricQueries.spec.tsx b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.spec.tsx
new file mode 100644
index 00000000000000..b4787ef20a0320
--- /dev/null
+++ b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.spec.tsx
@@ -0,0 +1,214 @@
+import type {DragEndEvent} from '@dnd-kit/core';
+
+import {act, renderHookWithProviders} from 'sentry-test/reactTestingLibrary';
+
+import {EQUATION_PREFIX} from 'sentry/utils/discover/fields';
+import {useSortableMetricQueries} from 'sentry/views/explore/metrics/hooks/useSortableMetricQueries';
+import {MultiMetricsQueryParamsProvider} from 'sentry/views/explore/metrics/multiMetricsQueryParams';
+import {useMultiMetricsQueryParams} from 'sentry/views/explore/metrics/multiMetricsQueryParams';
+import {
+ isVisualizeEquation,
+ VisualizeEquation,
+ VisualizeFunction,
+} from 'sentry/views/explore/queryParams/visualize';
+
+function Wrapper({children}: {children: React.ReactNode}) {
+ return {children};
+}
+
+describe('useSortableMetricQueries', () => {
+ it('uses stable labels as sortable ids', () => {
+ const {result} = renderHookWithProviders(useSortableMetricQueries, {
+ additionalWrapper: Wrapper,
+ initialRouterConfig: {
+ location: {
+ pathname: '/organizations/org-slug/explore/metrics/',
+ query: {
+ metric: [
+ JSON.stringify({
+ metric: {name: 'foo', type: 'counter'},
+ query: '',
+ aggregateFields: [
+ new VisualizeFunction('sum(value,foo,counter,-)').serialize(),
+ ],
+ aggregateSortBys: [],
+ mode: 'samples',
+ }),
+ JSON.stringify({
+ metric: {name: 'bar', type: 'counter'},
+ query: '',
+ aggregateFields: [
+ new VisualizeFunction('sum(value,bar,counter,-)').serialize(),
+ ],
+ aggregateSortBys: [],
+ mode: 'samples',
+ }),
+ ],
+ },
+ },
+ },
+ });
+
+ expect(result.current.sortableItems.map(({id}) => id)).toEqual(['A', 'B']);
+ });
+
+ it('filters sortable items by query type and reorders within that section', () => {
+ const {result} = renderHookWithProviders(
+ () => {
+ const sortable = useSortableMetricQueries({
+ predicate: metricQuery =>
+ !isVisualizeEquation(metricQuery.queryParams.visualizes[0]!),
+ });
+ const metricQueries = useMultiMetricsQueryParams();
+ return {sortable, metricQueries};
+ },
+ {
+ additionalWrapper: Wrapper,
+ initialRouterConfig: {
+ location: {
+ pathname: '/organizations/org-slug/explore/metrics/',
+ query: {
+ metric: [
+ JSON.stringify({
+ metric: {name: 'foo', type: 'counter'},
+ query: '',
+ aggregateFields: [
+ new VisualizeFunction('sum(value,foo,counter,-)').serialize(),
+ ],
+ aggregateSortBys: [],
+ mode: 'samples',
+ }),
+ JSON.stringify({
+ metric: {name: 'bar', type: 'counter'},
+ query: '',
+ aggregateFields: [
+ new VisualizeFunction('sum(value,bar,counter,-)').serialize(),
+ ],
+ aggregateSortBys: [],
+ mode: 'samples',
+ }),
+ JSON.stringify({
+ metric: {name: '', type: ''},
+ query: '',
+ aggregateFields: [new VisualizeEquation(EQUATION_PREFIX).serialize()],
+ aggregateSortBys: [],
+ mode: 'samples',
+ }),
+ ],
+ },
+ },
+ },
+ }
+ );
+
+ expect(result.current.sortable.sortableItems.map(({id}) => id)).toEqual(['A', 'B']);
+ expect(result.current.metricQueries.map(metricQuery => metricQuery.label)).toEqual([
+ 'A',
+ 'B',
+ 'ƒ1',
+ ]);
+
+ act(() => {
+ result.current.sortable.onDragEnd({
+ active: {id: 'B'},
+ over: {id: 'A'},
+ } as DragEndEvent);
+ });
+
+ expect(result.current.metricQueries.map(metricQuery => metricQuery.label)).toEqual([
+ 'B',
+ 'A',
+ 'ƒ1',
+ ]);
+ });
+
+ it('ignores drops outside the current section', () => {
+ const {result} = renderHookWithProviders(
+ () => {
+ const sortable = useSortableMetricQueries({
+ predicate: metricQuery =>
+ !isVisualizeEquation(metricQuery.queryParams.visualizes[0]!),
+ });
+ const metricQueries = useMultiMetricsQueryParams();
+ return {sortable, metricQueries};
+ },
+ {
+ additionalWrapper: Wrapper,
+ initialRouterConfig: {
+ location: {
+ pathname: '/organizations/org-slug/explore/metrics/',
+ query: {
+ metric: [
+ JSON.stringify({
+ metric: {name: 'foo', type: 'counter'},
+ query: '',
+ aggregateFields: [
+ new VisualizeFunction('sum(value,foo,counter,-)').serialize(),
+ ],
+ aggregateSortBys: [],
+ mode: 'samples',
+ }),
+ JSON.stringify({
+ metric: {name: '', type: ''},
+ query: '',
+ aggregateFields: [new VisualizeEquation(EQUATION_PREFIX).serialize()],
+ aggregateSortBys: [],
+ mode: 'samples',
+ }),
+ ],
+ },
+ },
+ },
+ }
+ );
+
+ act(() => {
+ result.current.sortable.onDragEnd({
+ active: {id: 'A'},
+ over: {id: 'ƒ1'},
+ } as DragEndEvent);
+ });
+
+ expect(result.current.metricQueries.map(metricQuery => metricQuery.label)).toEqual([
+ 'A',
+ 'ƒ1',
+ ]);
+ });
+
+ it('reorders duplicate queries by label ids', () => {
+ const duplicateQuery = JSON.stringify({
+ metric: {name: 'foo', type: 'counter'},
+ query: '',
+ aggregateFields: [new VisualizeFunction('sum(value,foo,counter,-)').serialize()],
+ aggregateSortBys: [],
+ mode: 'samples',
+ });
+
+ const {result} = renderHookWithProviders(useSortableMetricQueries, {
+ additionalWrapper: Wrapper,
+ initialRouterConfig: {
+ location: {
+ pathname: '/organizations/org-slug/explore/metrics/',
+ query: {
+ metric: [duplicateQuery, duplicateQuery],
+ },
+ },
+ },
+ });
+
+ expect(
+ result.current.sortableItems.map(({metricQuery}) => metricQuery.label)
+ ).toEqual(['A', 'B']);
+
+ act(() => {
+ result.current.onDragEnd({
+ active: {id: 'A'},
+ over: {id: 'B'},
+ } as DragEndEvent);
+ });
+
+ expect(
+ result.current.sortableItems.map(({metricQuery}) => metricQuery.label)
+ ).toEqual(['B', 'A']);
+ });
+});
diff --git a/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx
new file mode 100644
index 00000000000000..3113fb68b4b1ac
--- /dev/null
+++ b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx
@@ -0,0 +1,73 @@
+import {useCallback, useMemo, useState} from 'react';
+import type {DragEndEvent} from '@dnd-kit/core';
+import {KeyboardSensor, PointerSensor, useSensor, useSensors} from '@dnd-kit/core';
+import {arrayMove, sortableKeyboardCoordinates} from '@dnd-kit/sortable';
+
+import type {MetricQuery} from 'sentry/views/explore/metrics/metricQuery';
+import {
+ useMultiMetricsQueryParams,
+ useReorderMetricQueries,
+} from 'sentry/views/explore/metrics/multiMetricsQueryParams';
+
+interface UseSortableMetricQueriesOptions {
+ predicate?: (metricQuery: MetricQuery) => boolean;
+}
+
+export function useSortableMetricQueries({
+ predicate,
+}: UseSortableMetricQueriesOptions = {}) {
+ const metricQueries = useMultiMetricsQueryParams();
+ const reorderMetricQueries = useReorderMetricQueries();
+ const [isDragging, setIsDragging] = useState(false);
+
+ const sortableItems = useMemo(() => {
+ return metricQueries.flatMap((metricQuery, index) =>
+ predicate?.(metricQuery) === false
+ ? []
+ : [
+ {
+ id: metricQuery.label ?? String(index),
+ metricQuery,
+ index,
+ },
+ ]
+ );
+ }, [metricQueries, predicate]);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
+ const onDragStart = useCallback(() => {
+ setIsDragging(true);
+ }, []);
+
+ const onDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ setIsDragging(false);
+ const {active, over} = event;
+ if (active.id !== over?.id) {
+ const oldIndex = sortableItems.find(({id}) => id === active.id)?.index;
+ const newIndex = sortableItems.find(({id}) => id === over?.id)?.index;
+
+ if (oldIndex === undefined || newIndex === undefined) return;
+
+ reorderMetricQueries(
+ arrayMove([...metricQueries], oldIndex, newIndex),
+ oldIndex,
+ newIndex
+ );
+ }
+ },
+ [sortableItems, metricQueries, reorderMetricQueries]
+ );
+
+ const onDragCancel = useCallback(() => {
+ setIsDragging(false);
+ }, []);
+
+ return {sortableItems, sensors, onDragStart, onDragEnd, onDragCancel, isDragging};
+}
diff --git a/static/app/views/explore/metrics/hooks/useStableLabels.tsx b/static/app/views/explore/metrics/hooks/useStableLabels.tsx
index c411cdedf75598..8c8af014e59b4b 100644
--- a/static/app/views/explore/metrics/hooks/useStableLabels.tsx
+++ b/static/app/views/explore/metrics/hooks/useStableLabels.tsx
@@ -95,6 +95,25 @@ export function useStableLabels(queries: BaseMetricQuery[]) {
remove(position: number) {
labelsRef.current = labelsRef.current.filter((_, j) => j !== position);
},
+ move(from: number, to: number) {
+ if (
+ from === to ||
+ from < 0 ||
+ to < 0 ||
+ from >= labelsRef.current.length ||
+ to >= labelsRef.current.length
+ ) {
+ return;
+ }
+
+ const next = [...labelsRef.current];
+ const [label] = next.splice(from, 1);
+ if (!label) {
+ return;
+ }
+ next.splice(to, 0, label);
+ labelsRef.current = next;
+ },
}),
[]
);
diff --git a/static/app/views/explore/metrics/metricInfoTabs/index.tsx b/static/app/views/explore/metrics/metricInfoTabs/index.tsx
index a576e1bb62fc72..1f70a8f9656eca 100644
--- a/static/app/views/explore/metrics/metricInfoTabs/index.tsx
+++ b/static/app/views/explore/metrics/metricInfoTabs/index.tsx
@@ -52,16 +52,7 @@ export function MetricInfoTabs({
>
{orientation === 'right' || visualize.visible ? (
-
-
-
- {t('Samples')}
-
-
- {t('Aggregates')}
-
-
-
+
{additionalActions}
) : null}
@@ -86,3 +77,24 @@ export function MetricInfoTabs({
);
}
+
+function MetricInfoTabList({
+ orientation,
+ contentsHidden,
+}: {
+ orientation: TableOrientation;
+ contentsHidden?: boolean;
+}) {
+ return (
+
+
+
+ {t('Samples')}
+
+
+ {t('Aggregates')}
+
+
+
+ );
+}
diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx
index 1a36e0e43033a8..ee186683051bf3 100644
--- a/static/app/views/explore/metrics/metricPanel/index.tsx
+++ b/static/app/views/explore/metrics/metricPanel/index.tsx
@@ -1,9 +1,13 @@
-import {useState} from 'react';
+import {Activity, Fragment, useRef, useState} from 'react';
+import type {SyntheticListenerMap} from '@dnd-kit/core/dist/hooks/utilities';
-import {Container, Stack} from '@sentry/scraps/layout';
+import {Container, Grid, Stack} from '@sentry/scraps/layout';
+import {Text} from '@sentry/scraps/text';
import {Panel} from 'sentry/components/panels/panel';
import {PanelBody} from 'sentry/components/panels/panelBody';
+import {Placeholder} from 'sentry/components/placeholder';
+import {t} from 'sentry/locale';
import {useChartInterval} from 'sentry/utils/useChartInterval';
import {useOrganization} from 'sentry/utils/useOrganization';
import {useMetricsPanelAnalytics} from 'sentry/views/explore/hooks/useAnalytics';
@@ -32,10 +36,14 @@ import {
const RESULT_LIMIT = 50;
const TWO_MINUTE_DELAY = 120;
-interface MetricPanelProps {
+interface MetricPanelProps extends React.HTMLAttributes {
queryIndex: number;
queryLabel: string;
traceMetric: TraceMetric;
+ dragListeners?: SyntheticListenerMap;
+ isAnyDragging?: boolean;
+ isDragging?: boolean;
+ ref?: React.Ref;
references?: Set;
}
@@ -44,6 +52,12 @@ export function MetricPanel({
queryIndex,
queryLabel,
references,
+ dragListeners,
+ isAnyDragging,
+ isDragging,
+ style,
+ ref,
+ ...rest
}: MetricPanelProps) {
const organization = useOrganization();
const {
@@ -95,9 +109,11 @@ export function MetricPanel({
panelIndex: queryIndex,
});
+ const contentHeightRef = useRef(null);
+
if (hasMetricsUIRefresh) {
return (
-
+
@@ -105,18 +121,37 @@ export function MetricPanel({
traceMetric={traceMetric}
queryLabel={queryLabel}
references={references}
+ dragListeners={dragListeners}
/>
{visualize.visible ? (
-
+
+ {isAnyDragging ? (
+
+ ) : null}
+
+ {
+ if (!isAnyDragging && containerRef) {
+ contentHeightRef.current = containerRef.offsetHeight ?? null;
+ }
+ }}
+ >
+
+
+
+
) : null}
@@ -153,3 +188,38 @@ export function MetricPanel({
);
}
+
+function DnDPlaceholder({
+ contentHeight,
+ isDragging,
+}: {
+ contentHeight: number | null;
+ isDragging: boolean | undefined;
+}) {
+ return (
+
+
+
+
+ {isDragging ? (
+
+ {t(
+ "Charts are hidden while reordering. They're too expensive to drag along for the ride."
+ )}
+
+ ) : null}
+
+
+
+
+ {isDragging ? (
+
+ {t("We gotta hide the tables too, they're also pretty expensive.")}
+
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/static/app/views/explore/metrics/metricPanel/sortableMetricPanel.tsx b/static/app/views/explore/metrics/metricPanel/sortableMetricPanel.tsx
new file mode 100644
index 00000000000000..a6fc1cabe58196
--- /dev/null
+++ b/static/app/views/explore/metrics/metricPanel/sortableMetricPanel.tsx
@@ -0,0 +1,48 @@
+import {useSortable} from '@dnd-kit/sortable';
+import {CSS} from '@dnd-kit/utilities';
+
+import {MetricPanel} from 'sentry/views/explore/metrics/metricPanel';
+import type {TraceMetric} from 'sentry/views/explore/metrics/metricQuery';
+
+interface SortableMetricPanelProps {
+ canDrag: boolean;
+ isAnyDragging: boolean;
+ queryIndex: number;
+ queryLabel: string;
+ sortableId: string;
+ traceMetric: TraceMetric;
+ references?: Set;
+}
+
+export function SortableMetricPanel({
+ sortableId,
+ traceMetric,
+ queryIndex,
+ queryLabel,
+ references,
+ isAnyDragging,
+ canDrag,
+}: SortableMetricPanelProps) {
+ const {attributes, listeners, setNodeRef, transform, isDragging} = useSortable({
+ id: sortableId,
+ transition: null,
+ });
+
+ return (
+
+ );
+}
diff --git a/static/app/views/explore/metrics/metricToolbar/index.tsx b/static/app/views/explore/metrics/metricToolbar/index.tsx
index c1d535eca73969..479355242feaa3 100644
--- a/static/app/views/explore/metrics/metricToolbar/index.tsx
+++ b/static/app/views/explore/metrics/metricToolbar/index.tsx
@@ -1,9 +1,11 @@
import {Fragment, useCallback} from 'react';
+import type {SyntheticListenerMap} from '@dnd-kit/core/dist/hooks/utilities';
import {Flex, Grid} from '@sentry/scraps/layout';
import {ArithmeticBuilder} from 'sentry/components/arithmeticBuilder';
import type {Expression} from 'sentry/components/arithmeticBuilder/expression';
+import {DragReorderButton} from 'sentry/components/dnd/dragReorderButton';
import {EQUATION_PREFIX} from 'sentry/utils/discover/fields';
import {useBreakpoints} from 'sentry/utils/useBreakpoints';
import {useOrganization} from 'sentry/utils/useOrganization';
@@ -29,10 +31,16 @@ import {
interface MetricToolbarProps {
queryLabel: string;
traceMetric: TraceMetric;
+ dragListeners?: SyntheticListenerMap;
references?: Set;
}
-export function MetricToolbar({traceMetric, queryLabel, references}: MetricToolbarProps) {
+export function MetricToolbar({
+ traceMetric,
+ queryLabel,
+ references,
+ dragListeners,
+}: MetricToolbarProps) {
const organization = useOrganization();
const breakpoints = useBreakpoints();
const isNarrow = !breakpoints.md;
@@ -45,7 +53,7 @@ export function MetricToolbar({traceMetric, queryLabel, references}: MetricToolb
const setTraceMetric = useSetTraceMetric();
// We need at least one metric visualized, but equations should always
- // be removable
+ // be removable.
const canRemoveMetric =
metricQueries.filter(q => isVisualizeFunction(q.queryParams.visualizes[0]!)).length >
1 || isVisualizeEquation(visualize);
@@ -61,6 +69,14 @@ export function MetricToolbar({traceMetric, queryLabel, references}: MetricToolb
[setVisualize, visualize]
);
+ const dndGrid = dragListeners ? 'auto ' : '';
+ const removeMetric = canRemoveMetric ? '24px' : '0';
+ const columns = isVisualizeFunction(visualize)
+ ? isNarrow
+ ? `${dndGrid}auto 1fr 1fr ${removeMetric}`
+ : `${dndGrid}auto 2fr 3fr 6fr ${removeMetric}`
+ : `${dndGrid}auto 1fr ${removeMetric}`;
+
if (canUseMetricsUIRefresh(organization)) {
return (
-
+
+ {dragListeners ? : null}
{
expect(screen.getByText('Add Equation')).toBeInTheDocument();
});
+ it('renders aggregate and equation panels in separate sections in refresh layout', async () => {
+ const orgWithFeatures = OrganizationFixture({
+ features: [
+ 'tracemetrics-enabled',
+ 'tracemetrics-equations-in-explore',
+ 'tracemetrics-ui-refresh',
+ ],
+ });
+ MockApiClient.addMockResponse({
+ url: `/organizations/${orgWithFeatures.slug}/events/`,
+ method: 'GET',
+ body: {data: []},
+ });
+
+ render(
+
+
+ ,
+ {
+ organization: orgWithFeatures,
+ initialRouterConfig: {
+ location: {
+ pathname: '/organizations/:orgId/explore/metrics/',
+ query: {
+ start: '2025-04-10T14%3A37%3A55',
+ end: '2025-04-10T20%3A04%3A51',
+ metric: [
+ JSON.stringify({
+ metric: {name: 'bar', type: 'distribution'},
+ query: '',
+ aggregateFields: [
+ new VisualizeFunction('p50(value,bar,distribution,-)').serialize(),
+ ],
+ aggregateSortBys: [],
+ mode: 'samples',
+ }),
+ JSON.stringify({
+ metric: {name: '', type: ''},
+ query: '',
+ aggregateFields: [new VisualizeEquation(EQUATION_PREFIX).serialize()],
+ aggregateSortBys: [],
+ mode: 'samples',
+ }),
+ ],
+ title: 'Test Title',
+ },
+ },
+ route: '/organizations/:orgId/explore/metrics/',
+ },
+ }
+ );
+
+ const aggregateSection = await screen.findByTestId('aggregate-metric-panels');
+ const equationSection = screen.getByTestId('equation-metric-panels');
+
+ expect(within(aggregateSection).getAllByTestId('metric-panel')).toHaveLength(1);
+ expect(within(equationSection).getAllByTestId('metric-panel')).toHaveLength(1);
+ });
+
it('disables both Add Metric and Add Equation buttons when the maximum number of metric queries is reached', async () => {
const metricQueryWithGroupBy = JSON.stringify({
metric: {name: 'bar', type: 'distribution'},
diff --git a/static/app/views/explore/metrics/metricsTab.tsx b/static/app/views/explore/metrics/metricsTab.tsx
index c0762795e28337..a09b9b01f52fc0 100644
--- a/static/app/views/explore/metrics/metricsTab.tsx
+++ b/static/app/views/explore/metrics/metricsTab.tsx
@@ -1,6 +1,9 @@
+import {closestCenter, DndContext} from '@dnd-kit/core';
+import {SortableContext, verticalListSortingStrategy} from '@dnd-kit/sortable';
import styled from '@emotion/styled';
import {Container, Flex, Stack} from '@sentry/scraps/layout';
+import {Separator} from '@sentry/scraps/separator';
import * as Layout from 'sentry/components/layouts/thirds';
import type {DatePageFilterProps} from 'sentry/components/pageFilters/date/datePageFilter';
@@ -20,7 +23,9 @@ import {ToolbarVisualizeAddChart} from 'sentry/views/explore/components/toolbar/
import {useMetricsAnalytics} from 'sentry/views/explore/hooks/useAnalytics';
import {useMetricOptions} from 'sentry/views/explore/hooks/useMetricOptions';
import {useMetricReferences} from 'sentry/views/explore/metrics/hooks/useMetricReferences';
+import {useSortableMetricQueries} from 'sentry/views/explore/metrics/hooks/useSortableMetricQueries';
import {MetricPanel} from 'sentry/views/explore/metrics/metricPanel';
+import {SortableMetricPanel} from 'sentry/views/explore/metrics/metricPanel/sortableMetricPanel';
import {
canUseMetricsEquations,
canUseMetricsUIRefresh,
@@ -38,6 +43,7 @@ import {
FilterBarWithSaveAsContainer,
StyledPageFilterBar,
} from 'sentry/views/explore/metrics/styles';
+import {isVisualizeEquation} from 'sentry/views/explore/queryParams/visualize';
export const METRICS_CHART_GROUP = 'metrics-charts-group';
type MetricsTabProps = {
@@ -214,6 +220,19 @@ function MetricsTabBodySection() {
isMetricOptionsEmpty,
});
const references = useMetricReferences();
+ const aggregateMetricQueries = useSortableMetricQueries({
+ predicate: metricQuery =>
+ !isVisualizeEquation(metricQuery.queryParams.visualizes[0]!),
+ });
+ const equationMetricQueries = useSortableMetricQueries({
+ predicate: metricQuery => isVisualizeEquation(metricQuery.queryParams.visualizes[0]!),
+ });
+ const isDragging =
+ aggregateMetricQueries.isDragging || equationMetricQueries.isDragging;
+ const showSectionSeparator =
+ isDragging &&
+ aggregateMetricQueries.sortableItems.length > 0 &&
+ equationMetricQueries.sortableItems.length > 0;
// Cannot add metric queries beyond Z
const isAddMetricDisabled =
@@ -225,25 +244,27 @@ function MetricsTabBodySection() {
- {metricQueries.map((metricQuery, index) => {
- return (
-
-
-
- );
- })}
+
+ {showSectionSeparator ? (
+
+
+
+ ) : null}
+
;
+ sortableQueries: ReturnType;
+}
+
+function SortableMetricPanelSection({
+ dataTestId,
+ sortableQueries,
+ references,
+ isAnyDragging,
+}: SortableMetricPanelSectionProps) {
+ const {sortableItems, sensors, onDragStart, onDragEnd, onDragCancel} = sortableQueries;
+
+ if (!sortableItems.length) {
+ return null;
+ }
+
+ return (
+
+
+
+ {sortableItems.map(({id, metricQuery, index}) => {
+ return (
+
+ 1}
+ />
+
+ );
+ })}
+
+
+
+ );
+}
+
const MetricsQueryBuilderContainer = styled(Container)`
padding: ${p => p.theme.space.xl};
background-color: ${p => p.theme.tokens.background.primary};
diff --git a/static/app/views/explore/metrics/multiMetricsQueryParams.spec.tsx b/static/app/views/explore/metrics/multiMetricsQueryParams.spec.tsx
index d55d7b18e2fe4d..52ca744d43b430 100644
--- a/static/app/views/explore/metrics/multiMetricsQueryParams.spec.tsx
+++ b/static/app/views/explore/metrics/multiMetricsQueryParams.spec.tsx
@@ -9,6 +9,7 @@ import {
MultiMetricsQueryParamsProvider,
useAddMetricQuery,
useMultiMetricsQueryParams,
+ useReorderMetricQueries,
} from 'sentry/views/explore/metrics/multiMetricsQueryParams';
import {ReadableQueryParams} from 'sentry/views/explore/queryParams/readableQueryParams';
import {
@@ -362,6 +363,68 @@ describe('MultiMetricsQueryParamsProvider', () => {
expect(result.current[0]).toEqual(expect.objectContaining({label: 'A'}));
expect(result.current[1]).toEqual(expect.objectContaining({label: 'ƒ1'}));
});
+
+ it('keeps labels attached to query identity when reordering', () => {
+ const {result} = renderHookWithProviders(
+ () => {
+ const metricQueries = useMultiMetricsQueryParams();
+ const reorder = useReorderMetricQueries();
+ return {metricQueries, reorder};
+ },
+ {
+ additionalWrapper: Wrapper,
+ initialRouterConfig: {
+ location: {
+ pathname: '/organizations/org-slug/explore/metrics/',
+ query: {
+ metric: [
+ JSON.stringify({
+ metric: {name: 'foo', type: 'counter'},
+ query: '',
+ aggregateFields: [
+ new VisualizeFunction('sum(value,foo,counter,-)').serialize(),
+ ],
+ aggregateSortBys: [],
+ mode: 'samples',
+ }),
+ JSON.stringify({
+ metric: {name: 'bar', type: 'counter'},
+ query: '',
+ aggregateFields: [
+ new VisualizeFunction('sum(value,bar,counter,-)').serialize(),
+ ],
+ aggregateSortBys: [],
+ mode: 'samples',
+ }),
+ ],
+ },
+ },
+ },
+ }
+ );
+
+ expect(result.current.metricQueries[0]).toEqual(
+ expect.objectContaining({label: 'A', metric: {name: 'foo', type: 'counter'}})
+ );
+ expect(result.current.metricQueries[1]).toEqual(
+ expect.objectContaining({label: 'B', metric: {name: 'bar', type: 'counter'}})
+ );
+
+ act(() => {
+ result.current.reorder(
+ [result.current.metricQueries[1]!, result.current.metricQueries[0]!],
+ 0,
+ 1
+ );
+ });
+
+ expect(result.current.metricQueries[0]).toEqual(
+ expect.objectContaining({label: 'B', metric: {name: 'bar', type: 'counter'}})
+ );
+ expect(result.current.metricQueries[1]).toEqual(
+ expect.objectContaining({label: 'A', metric: {name: 'foo', type: 'counter'}})
+ );
+ });
});
describe('useAddMetricQuery', () => {
diff --git a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx
index 6179e7012272e0..fc965e7b2cc891 100644
--- a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx
+++ b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx
@@ -1,4 +1,4 @@
-import {useMemo, type ReactNode} from 'react';
+import {useCallback, useMemo, type ReactNode} from 'react';
import type {Location} from 'history';
import {defined} from 'sentry/utils';
@@ -37,6 +37,7 @@ export const MAX_METRICS_ALLOWED = 8;
interface MultiMetricsQueryParamsContextValue {
insertLabelAtIndex: (position: number, label: string) => void;
metricQueries: MetricQuery[];
+ reorderLabels: (from: number, to: number) => void;
}
const [
@@ -173,6 +174,7 @@ export function MultiMetricsQueryParamsProvider({
return {
insertLabelAtIndex: labels.insert,
+ reorderLabels: labels.move,
metricQueries: metricQueries.map((metric: BaseMetricQuery, index: number) => {
return {
...metric,
@@ -253,3 +255,26 @@ export function useAddMetricQuery({
navigate(target);
};
}
+
+export function useReorderMetricQueries() {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const {reorderLabels}: MultiMetricsQueryParamsContextValue =
+ useMultiMetricsQueryParamsContext();
+
+ return useCallback(
+ (reorderedQueries: BaseMetricQuery[], oldIndex: number, newIndex: number) => {
+ // Keep labels attached to query identity during drag reorder.
+ reorderLabels(oldIndex, newIndex);
+
+ const target = {...location, query: {...location.query}};
+ target.query.metric = reorderedQueries
+ .map((metricQuery: BaseMetricQuery) => encodeMetricQueryParams(metricQuery))
+ .filter(defined)
+ .filter(Boolean);
+
+ navigate(target);
+ },
+ [location, navigate, reorderLabels]
+ );
+}