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] + ); +}