From 9390b81bec4fb10758a616e4c2b56671e3136872 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 11:11:05 -0300 Subject: [PATCH 01/27] feat(metrics): Add useReorderMetricQueries hook Add a hook that encodes a reordered list of metric queries into the URL query params and navigates, providing the foundation for drag-and-drop reordering of metric panels. Co-Authored-By: Claude Opus 4.6 --- .../metrics/multiMetricsQueryParams.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx index 94bcf60384bddf..d765f3b278b582 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'; @@ -220,3 +220,21 @@ export function useAddMetricQuery({ navigate(target); }; } + +export function useReorderMetricQueries() { + const location = useLocation(); + const navigate = useNavigate(); + + return useCallback( + (reorderedQueries: BaseMetricQuery[]) => { + const target = {...location, query: {...location.query}}; + target.query.metric = reorderedQueries + .map((metricQuery: BaseMetricQuery) => encodeMetricQueryParams(metricQuery)) + .filter(defined) + .filter(Boolean); + + navigate(target); + }, + [location, navigate] + ); +} From 5ea1219e124732ca50f563d93d9d6bfd8d5eaa50 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 11:11:31 -0300 Subject: [PATCH 02/27] feat(metrics): Accept drag-and-drop props in MetricPanel and MetricToolbar MetricPanel now accepts ref, style, dragListeners, and isAnyDragging props. When a drag is active, chart content is replaced with a placeholder to avoid expensive re-renders. MetricToolbar renders a DragReorderButton when drag listeners are provided. Co-Authored-By: Claude Opus 4.6 --- .../explore/metrics/metricPanel/index.tsx | 68 +++++++++++++++---- .../explore/metrics/metricToolbar/index.tsx | 21 ++++-- 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index 58b16db2f04cc7..2aff269d32e5af 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 {useRef, useState} from 'react'; +import type {SyntheticListenerMap} from '@dnd-kit/core/dist/hooks/utilities'; import {Container, 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'; @@ -35,10 +39,22 @@ const TWO_MINUTE_DELAY = 120; interface MetricPanelProps { queryIndex: number; traceMetric: TraceMetric; + dragListeners?: SyntheticListenerMap; + isAnyDragging?: boolean; + ref?: React.Ref; references?: Set; + style?: React.CSSProperties; } -export function MetricPanel({traceMetric, queryIndex, references}: MetricPanelProps) { +export function MetricPanel({ + traceMetric, + queryIndex, + references, + dragListeners, + isAnyDragging, + style, + ref, +}: MetricPanelProps) { const organization = useOrganization(); const { orientation, @@ -89,9 +105,18 @@ export function MetricPanel({traceMetric, queryIndex, references}: MetricPanelPr panelIndex: queryIndex, }); + const contentRef = useRef(null); + const contentHeightRef = useRef(null); + + // Capture the content height whenever the content is rendered so the + // placeholder can match it exactly and avoid layout shift during drag. + if (!isAnyDragging && contentRef.current) { + contentHeightRef.current = contentRef.current.offsetHeight; + } + if (hasMetricsUIRefresh) { return ( - + @@ -99,18 +124,37 @@ export function MetricPanel({traceMetric, queryIndex, references}: MetricPanelPr traceMetric={traceMetric} queryIndex={queryIndex} references={references} + dragListeners={dragListeners} /> {visualize.visible ? ( - + isAnyDragging ? ( + + + + {t( + 'Hold on to your butts! Charts are tucked away while you reorder. Too expensive to drag along for the ride.' + )} + + + + ) : ( +
+ +
+ ) ) : null}
diff --git a/static/app/views/explore/metrics/metricToolbar/index.tsx b/static/app/views/explore/metrics/metricToolbar/index.tsx index 6fbc3f8529a81c..75bf5be87fb741 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 {useOrganization} from 'sentry/utils/useOrganization'; import {type TraceMetric} from 'sentry/views/explore/metrics/metricQuery'; @@ -28,10 +30,16 @@ import { interface MetricToolbarProps { queryIndex: number; traceMetric: TraceMetric; + dragListeners?: SyntheticListenerMap; references?: Set; } -export function MetricToolbar({traceMetric, queryIndex, references}: MetricToolbarProps) { +export function MetricToolbar({ + traceMetric, + queryIndex, + references, + dragListeners, +}: MetricToolbarProps) { const organization = useOrganization(); const metricQueries = useMultiMetricsQueryParams(); const visualize = useMetricVisualize(); @@ -61,14 +69,19 @@ export function MetricToolbar({traceMetric, queryIndex, references}: MetricToolb gap="md" columns={ isVisualizeFunction(visualize) - ? `auto 2fr 3fr 6fr ${canRemoveMetric ? '24px' : '0'}` - : `auto 1fr ${canRemoveMetric ? '24px' : '0'}` + ? `auto auto 2fr 3fr 6fr ${canRemoveMetric ? '24px' : '0'}` + : `auto auto 1fr ${canRemoveMetric ? '24px' : '0'}` } data-test-id="metric-toolbar" - paddingLeft="lg" + paddingLeft="md" paddingRight="lg" paddingTop="md" > + {dragListeners ? ( + + ) : ( + + )} Date: Fri, 10 Apr 2026 11:11:48 -0300 Subject: [PATCH 03/27] feat(metrics): Wire up dnd-kit for metric panel reordering Add SortableMetricPanel wrapper using @dnd-kit/sortable and integrate DndContext into MetricsTabBodySection. Panels can now be reordered via drag-and-drop, with the new order persisted to URL query params. Co-Authored-By: Claude Opus 4.6 --- .../metricPanel/sortableMetricPanel.tsx | 42 ++++++ .../app/views/explore/metrics/metricsTab.tsx | 127 +++++++++++++++--- 2 files changed, 151 insertions(+), 18 deletions(-) create mode 100644 static/app/views/explore/metrics/metricPanel/sortableMetricPanel.tsx 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..ea18528ff6010a --- /dev/null +++ b/static/app/views/explore/metrics/metricPanel/sortableMetricPanel.tsx @@ -0,0 +1,42 @@ +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 { + isAnyDragging: boolean; + queryIndex: number; + sortableId: number; + traceMetric: TraceMetric; + references?: Set; +} + +export function SortableMetricPanel({ + sortableId, + traceMetric, + queryIndex, + references, + isAnyDragging, +}: SortableMetricPanelProps) { + const {attributes, listeners, setNodeRef, transform, isDragging} = useSortable({ + id: sortableId, + transition: null, + }); + + return ( + + ); +} diff --git a/static/app/views/explore/metrics/metricsTab.tsx b/static/app/views/explore/metrics/metricsTab.tsx index 67a47132e85954..9c8f30ec11ae86 100644 --- a/static/app/views/explore/metrics/metricsTab.tsx +++ b/static/app/views/explore/metrics/metricsTab.tsx @@ -1,3 +1,19 @@ +import {useCallback, useRef, useState} from 'react'; +import type {DragEndEvent} from '@dnd-kit/core'; +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; import styled from '@emotion/styled'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; @@ -8,6 +24,7 @@ import {DatePageFilter} from 'sentry/components/pageFilters/date/datePageFilter' import {EnvironmentPageFilter} from 'sentry/components/pageFilters/environment/environmentPageFilter'; import {ProjectPageFilter} from 'sentry/components/pageFilters/project/projectPageFilter'; import {t} from 'sentry/locale'; +import {uniqueId} from 'sentry/utils/guid'; import {useChartInterval} from 'sentry/utils/useChartInterval'; import {useOrganization} from 'sentry/utils/useOrganization'; import {WidgetSyncContextProvider} from 'sentry/views/dashboards/contexts/widgetSyncContext'; @@ -21,6 +38,7 @@ 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 {MetricPanel} from 'sentry/views/explore/metrics/metricPanel'; +import {SortableMetricPanel} from 'sentry/views/explore/metrics/metricPanel/sortableMetricPanel'; import { canUseMetricsEquations, canUseMetricsUIRefresh, @@ -32,6 +50,7 @@ import { MultiMetricsQueryParamsProvider, useAddMetricQuery, useMultiMetricsQueryParams, + useReorderMetricQueries, } from 'sentry/views/explore/metrics/multiMetricsQueryParams'; import { FilterBarWithSaveAsContainer, @@ -188,6 +207,61 @@ function MetricsQueryBuilderSection() { ); } +function useSortableMetricQueries() { + const metricQueries = useMultiMetricsQueryParams(); + const reorderMetricQueries = useReorderMetricQueries(); + const [isDragging, setIsDragging] = useState(false); + + const uniqueIdsRef = useRef([]); + uniqueIdsRef.current.length = Math.min( + uniqueIdsRef.current.length, + metricQueries.length + ); + while (uniqueIdsRef.current.length < metricQueries.length) { + uniqueIdsRef.current.push(uniqueId()); + } + + const sortableItems = metricQueries.map((metricQuery, i) => ({ + id: i + 1, + uniqueId: uniqueIdsRef.current[i]!, + metricQuery, + })); + + 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.findIndex(({id}) => id === active.id); + const newIndex = sortableItems.findIndex(({id}) => id === over?.id); + if (oldIndex < 0 || newIndex < 0) { + return; + } + uniqueIdsRef.current = arrayMove(uniqueIdsRef.current, oldIndex, newIndex); + reorderMetricQueries(arrayMove([...metricQueries], oldIndex, newIndex)); + } + }, + [sortableItems, metricQueries, reorderMetricQueries] + ); + + const onDragCancel = useCallback(() => { + setIsDragging(false); + }, []); + + return {sortableItems, sensors, onDragStart, onDragEnd, onDragCancel, isDragging}; +} + function MetricsTabBodySection() { const organization = useOrganization(); const metricQueries = useMultiMetricsQueryParams(); @@ -205,30 +279,47 @@ function MetricsTabBodySection() { isMetricOptionsEmpty, }); const references = useMetricReferences(); + const {sortableItems, sensors, onDragStart, onDragEnd, onDragCancel, isDragging} = + useSortableMetricQueries(); if (canUseMetricsUIRefresh(organization)) { return ( - {metricQueries.map((metricQuery, index) => { - return ( - - - - ); - })} + + + {sortableItems.map(({id, uniqueId: uid, metricQuery}, index) => { + return ( + + + + ); + })} + + Date: Fri, 10 Apr 2026 11:18:51 -0300 Subject: [PATCH 04/27] feat(metrics): Disable drag handle when only one metric panel Hide the drag reorder button and remove its grid column when there is only a single metric query, since reordering is not meaningful with one item. Co-Authored-By: Claude Opus 4.6 --- .../metricPanel/sortableMetricPanel.tsx | 4 +++- .../explore/metrics/metricToolbar/index.tsx | 18 ++++++++---------- .../app/views/explore/metrics/metricsTab.tsx | 1 + 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/static/app/views/explore/metrics/metricPanel/sortableMetricPanel.tsx b/static/app/views/explore/metrics/metricPanel/sortableMetricPanel.tsx index ea18528ff6010a..c08462af02a01f 100644 --- a/static/app/views/explore/metrics/metricPanel/sortableMetricPanel.tsx +++ b/static/app/views/explore/metrics/metricPanel/sortableMetricPanel.tsx @@ -5,6 +5,7 @@ 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; sortableId: number; @@ -18,6 +19,7 @@ export function SortableMetricPanel({ queryIndex, references, isAnyDragging, + canDrag, }: SortableMetricPanelProps) { const {attributes, listeners, setNodeRef, transform, isDragging} = useSortable({ id: sortableId, @@ -34,7 +36,7 @@ export function SortableMetricPanel({ traceMetric={traceMetric} queryIndex={queryIndex} references={references} - dragListeners={listeners} + dragListeners={canDrag ? listeners : undefined} isAnyDragging={isAnyDragging} {...attributes} /> diff --git a/static/app/views/explore/metrics/metricToolbar/index.tsx b/static/app/views/explore/metrics/metricToolbar/index.tsx index 75bf5be87fb741..f83c426c0af4f2 100644 --- a/static/app/views/explore/metrics/metricToolbar/index.tsx +++ b/static/app/views/explore/metrics/metricToolbar/index.tsx @@ -61,27 +61,25 @@ export function MetricToolbar({ [setVisualize, visualize] ); + const dndGrid = dragListeners ? 'auto' : ''; + const removeMetric = canRemoveMetric ? '24px' : '0'; + const columns = isVisualizeFunction(visualize) + ? `${dndGrid} auto 2fr 3fr 6fr ${removeMetric}` + : `${dndGrid} auto 1fr ${removeMetric}`; + if (canUseMetricsUIRefresh(organization)) { return ( - {dragListeners ? ( - - ) : ( - - )} + {dragListeners ? : null} 1} /> ); From de37489a5aff51c7038348889c9b40d1be04aa36 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 11:25:10 -0300 Subject: [PATCH 05/27] fix(metrics): Fix dnd accessibility, stable keys, and layout thrash Forward dnd-kit aria attributes through MetricPanel to the DOM so keyboard and screen reader users can interact with sortable panels. Replace index-based uniqueId array with a Map keyed by encoded query params so deletions and mid-list insertions no longer desync React keys with their queries. Move offsetHeight measurement from the render body into a useLayoutEffect to avoid forced synchronous reflow. Co-Authored-By: Claude Opus 4.6 --- .../explore/metrics/metricPanel/index.tsx | 20 ++++++----- .../app/views/explore/metrics/metricsTab.tsx | 33 +++++++++++-------- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index 2aff269d32e5af..2b4a7506bf4b03 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -1,4 +1,4 @@ -import {useRef, useState} from 'react'; +import {useLayoutEffect, useRef, useState} from 'react'; import type {SyntheticListenerMap} from '@dnd-kit/core/dist/hooks/utilities'; import {Container, Stack} from '@sentry/scraps/layout'; @@ -36,14 +36,13 @@ import { const RESULT_LIMIT = 50; const TWO_MINUTE_DELAY = 120; -interface MetricPanelProps { +interface MetricPanelProps extends React.HTMLAttributes { queryIndex: number; traceMetric: TraceMetric; dragListeners?: SyntheticListenerMap; isAnyDragging?: boolean; ref?: React.Ref; references?: Set; - style?: React.CSSProperties; } export function MetricPanel({ @@ -54,6 +53,7 @@ export function MetricPanel({ isAnyDragging, style, ref, + ...rest }: MetricPanelProps) { const organization = useOrganization(); const { @@ -108,15 +108,17 @@ export function MetricPanel({ const contentRef = useRef(null); const contentHeightRef = useRef(null); - // Capture the content height whenever the content is rendered so the - // placeholder can match it exactly and avoid layout shift during drag. - if (!isAnyDragging && contentRef.current) { - contentHeightRef.current = contentRef.current.offsetHeight; - } + // Capture the content height after layout so the placeholder can match it + // exactly and avoid layout shift during drag. + useLayoutEffect(() => { + if (!isAnyDragging && contentRef.current) { + contentHeightRef.current = contentRef.current.offsetHeight; + } + }); if (hasMetricsUIRefresh) { return ( - + diff --git a/static/app/views/explore/metrics/metricsTab.tsx b/static/app/views/explore/metrics/metricsTab.tsx index 45dc61b4cc7da5..8af464f2406dcb 100644 --- a/static/app/views/explore/metrics/metricsTab.tsx +++ b/static/app/views/explore/metrics/metricsTab.tsx @@ -39,6 +39,7 @@ import {useMetricOptions} from 'sentry/views/explore/hooks/useMetricOptions'; import {useMetricReferences} from 'sentry/views/explore/metrics/hooks/useMetricReferences'; import {MetricPanel} from 'sentry/views/explore/metrics/metricPanel'; import {SortableMetricPanel} from 'sentry/views/explore/metrics/metricPanel/sortableMetricPanel'; +import {encodeMetricQueryParams} from 'sentry/views/explore/metrics/metricQuery'; import { canUseMetricsEquations, canUseMetricsUIRefresh, @@ -212,21 +213,28 @@ function useSortableMetricQueries() { const reorderMetricQueries = useReorderMetricQueries(); const [isDragging, setIsDragging] = useState(false); - const uniqueIdsRef = useRef([]); - uniqueIdsRef.current.length = Math.min( - uniqueIdsRef.current.length, - metricQueries.length + // Map from encoded query identity → stable unique ID. This correctly + // handles deletions and mid-list insertions (unlike an index-based array). + const idMapRef = useRef>(new Map()); + const sortableItems = metricQueries.map((metricQuery, i) => { + const key = encodeMetricQueryParams(metricQuery); + let uid = idMapRef.current.get(key); + if (!uid) { + uid = uniqueId(); + idMapRef.current.set(key, uid); + } + return {id: i + 1, uniqueId: uid, metricQuery}; + }); + // Prune stale entries for queries that no longer exist. + const activeKeys = new Set( + sortableItems.map(item => encodeMetricQueryParams(item.metricQuery)) ); - while (uniqueIdsRef.current.length < metricQueries.length) { - uniqueIdsRef.current.push(uniqueId()); + for (const key of idMapRef.current.keys()) { + if (!activeKeys.has(key)) { + idMapRef.current.delete(key); + } } - const sortableItems = metricQueries.map((metricQuery, i) => ({ - id: i + 1, - uniqueId: uniqueIdsRef.current[i]!, - metricQuery, - })); - const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { @@ -248,7 +256,6 @@ function useSortableMetricQueries() { if (oldIndex < 0 || newIndex < 0) { return; } - uniqueIdsRef.current = arrayMove(uniqueIdsRef.current, oldIndex, newIndex); reorderMetricQueries(arrayMove([...metricQueries], oldIndex, newIndex)); } }, From f7d561ff97db3e88b75a9cd9a79a07515495d7d2 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 11:30:21 -0300 Subject: [PATCH 06/27] fix(metrics): Fix perf, duplicate keys, and memoization in dnd Add dependency array to useLayoutEffect to avoid forcing layout reflow on every render. Include index in sortable key generation so duplicate metric queries get distinct stable IDs. Wrap sortableItems in useMemo so onDragEnd useCallback actually memoizes. Fix leading space in grid columns template when drag handle is absent. Co-Authored-By: Claude Opus 4.6 --- .../explore/metrics/metricPanel/index.tsx | 2 +- .../explore/metrics/metricToolbar/index.tsx | 6 +-- .../app/views/explore/metrics/metricsTab.tsx | 41 +++++++++++-------- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index 2b4a7506bf4b03..eb7426629787ca 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -114,7 +114,7 @@ export function MetricPanel({ if (!isAnyDragging && contentRef.current) { contentHeightRef.current = contentRef.current.offsetHeight; } - }); + }, [isAnyDragging]); if (hasMetricsUIRefresh) { return ( diff --git a/static/app/views/explore/metrics/metricToolbar/index.tsx b/static/app/views/explore/metrics/metricToolbar/index.tsx index f83c426c0af4f2..f372583cf69fe9 100644 --- a/static/app/views/explore/metrics/metricToolbar/index.tsx +++ b/static/app/views/explore/metrics/metricToolbar/index.tsx @@ -61,11 +61,11 @@ export function MetricToolbar({ [setVisualize, visualize] ); - const dndGrid = dragListeners ? 'auto' : ''; + const dndGrid = dragListeners ? 'auto ' : ''; const removeMetric = canRemoveMetric ? '24px' : '0'; const columns = isVisualizeFunction(visualize) - ? `${dndGrid} auto 2fr 3fr 6fr ${removeMetric}` - : `${dndGrid} auto 1fr ${removeMetric}`; + ? `${dndGrid}auto 2fr 3fr 6fr ${removeMetric}` + : `${dndGrid}auto 1fr ${removeMetric}`; if (canUseMetricsUIRefresh(organization)) { return ( diff --git a/static/app/views/explore/metrics/metricsTab.tsx b/static/app/views/explore/metrics/metricsTab.tsx index 8af464f2406dcb..382bcac595989f 100644 --- a/static/app/views/explore/metrics/metricsTab.tsx +++ b/static/app/views/explore/metrics/metricsTab.tsx @@ -1,4 +1,4 @@ -import {useCallback, useRef, useState} from 'react'; +import {useCallback, useMemo, useRef, useState} from 'react'; import type {DragEndEvent} from '@dnd-kit/core'; import { closestCenter, @@ -215,25 +215,30 @@ function useSortableMetricQueries() { // Map from encoded query identity → stable unique ID. This correctly // handles deletions and mid-list insertions (unlike an index-based array). + // Map from positional key (encoded query + index) → stable unique ID. + // Including the index ensures duplicate queries get distinct keys. const idMapRef = useRef>(new Map()); - const sortableItems = metricQueries.map((metricQuery, i) => { - const key = encodeMetricQueryParams(metricQuery); - let uid = idMapRef.current.get(key); - if (!uid) { - uid = uniqueId(); - idMapRef.current.set(key, uid); - } - return {id: i + 1, uniqueId: uid, metricQuery}; - }); - // Prune stale entries for queries that no longer exist. - const activeKeys = new Set( - sortableItems.map(item => encodeMetricQueryParams(item.metricQuery)) - ); - for (const key of idMapRef.current.keys()) { - if (!activeKeys.has(key)) { - idMapRef.current.delete(key); + const sortableItems = useMemo(() => { + const items = metricQueries.map((metricQuery, i) => { + const key = `${i}::${encodeMetricQueryParams(metricQuery)}`; + let uid = idMapRef.current.get(key); + if (!uid) { + uid = uniqueId(); + idMapRef.current.set(key, uid); + } + return {id: i + 1, uniqueId: uid, metricQuery}; + }); + // Prune stale entries for queries that no longer exist. + const activeKeys = new Set( + items.map((item, i) => `${i}::${encodeMetricQueryParams(item.metricQuery)}`) + ); + for (const key of idMapRef.current.keys()) { + if (!activeKeys.has(key)) { + idMapRef.current.delete(key); + } } - } + return items; + }, [metricQueries]); const sensors = useSensors( useSensor(PointerSensor), From 61e22a19022680c671ac0df20b236b9cfe112855 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 11:34:30 -0300 Subject: [PATCH 07/27] fix(metrics): Show placeholder message only on the dragged panel Previously all panels displayed the placeholder text during drag. Now only the panel being actively dragged shows the message, while other panels show a blank placeholder to avoid chart rendering. Co-Authored-By: Claude Opus 4.6 --- .../views/explore/metrics/metricPanel/index.tsx | 14 +++++++++----- .../metrics/metricPanel/sortableMetricPanel.tsx | 1 + 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index eb7426629787ca..efec5dd47144b9 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -41,6 +41,7 @@ interface MetricPanelProps extends React.HTMLAttributes { traceMetric: TraceMetric; dragListeners?: SyntheticListenerMap; isAnyDragging?: boolean; + isDragging?: boolean; ref?: React.Ref; references?: Set; } @@ -51,6 +52,7 @@ export function MetricPanel({ references, dragListeners, isAnyDragging, + isDragging, style, ref, ...rest @@ -137,11 +139,13 @@ export function MetricPanel({ contentHeightRef.current ? `${contentHeightRef.current}px` : '200px' } > - - {t( - 'Hold on to your butts! Charts are tucked away while you reorder. Too expensive to drag along for the ride.' - )} - + {isDragging && ( + + {t( + 'Hold on to your butts! Charts are tucked away while you reorder. Too expensive to drag along for the ride.' + )} + + )} ) : ( diff --git a/static/app/views/explore/metrics/metricPanel/sortableMetricPanel.tsx b/static/app/views/explore/metrics/metricPanel/sortableMetricPanel.tsx index c08462af02a01f..500722890e3b72 100644 --- a/static/app/views/explore/metrics/metricPanel/sortableMetricPanel.tsx +++ b/static/app/views/explore/metrics/metricPanel/sortableMetricPanel.tsx @@ -38,6 +38,7 @@ export function SortableMetricPanel({ references={references} dragListeners={canDrag ? listeners : undefined} isAnyDragging={isAnyDragging} + isDragging={isDragging} {...attributes} /> ); From a7d33ee1167dcb905428632795a98c48ff398696 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 11:43:13 -0300 Subject: [PATCH 08/27] ref(metrics): Simplify dnd placeholder with ref callback and extract component Replace useLayoutEffect height capture with a ref callback on the container, removing an extra ref and effect. Extract the drag placeholder into a DnDPlaceholder component for clarity. Co-Authored-By: Claude Opus 4.6 --- .../explore/metrics/metricPanel/index.tsx | 61 +++++++++++-------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index efec5dd47144b9..4856ecb7a17351 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -1,4 +1,4 @@ -import {useLayoutEffect, useRef, useState} from 'react'; +import {useRef, useState} from 'react'; import type {SyntheticListenerMap} from '@dnd-kit/core/dist/hooks/utilities'; import {Container, Stack} from '@sentry/scraps/layout'; @@ -107,17 +107,8 @@ export function MetricPanel({ panelIndex: queryIndex, }); - const contentRef = useRef(null); const contentHeightRef = useRef(null); - // Capture the content height after layout so the placeholder can match it - // exactly and avoid layout shift during drag. - useLayoutEffect(() => { - if (!isAnyDragging && contentRef.current) { - contentHeightRef.current = contentRef.current.offsetHeight; - } - }, [isAnyDragging]); - if (hasMetricsUIRefresh) { return ( @@ -133,23 +124,18 @@ export function MetricPanel({ {visualize.visible ? ( isAnyDragging ? ( - - - {isDragging && ( - - {t( - 'Hold on to your butts! Charts are tucked away while you reorder. Too expensive to drag along for the ride.' - )} - - )} - - + ) : ( -
+ { + if (!isAnyDragging && containerRef) { + contentHeightRef.current = containerRef.offsetHeight ?? null; + } + }} + > -
+ ) ) : null}
@@ -197,3 +183,24 @@ export function MetricPanel({
); } + +interface DnDPlaceholderProps { + contentHeight: number | null; + isDragging: boolean | undefined; +} + +function DnDPlaceholder({contentHeight, isDragging}: DnDPlaceholderProps) { + return ( + + + {isDragging ? ( + + {t( + 'Hold on to your butts! Charts are tucked away while you reorder. Too expensive to drag along for the ride.' + )} + + ) : null} + + + ); +} From b33f0a6ab83fceec6a9e87ed621f722560315951 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 11:54:01 -0300 Subject: [PATCH 09/27] fix(metrics): Revise dnd placeholder copy to follow brand voice guidelines Front-load the informational content before the personality quip, per content & voice guidelines. Co-Authored-By: Claude Opus 4.6 --- static/app/views/explore/metrics/metricPanel/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index 4856ecb7a17351..df7214d4af0eba 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -196,7 +196,7 @@ function DnDPlaceholder({contentHeight, isDragging}: DnDPlaceholderProps) { {isDragging ? ( {t( - 'Hold on to your butts! Charts are tucked away while you reorder. Too expensive to drag along for the ride.' + 'Charts are hidden while reordering. Too expensive to drag along for the ride.' )} ) : null} From 942c94e0083923acd659ea7a3a1ac16e829f6326 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 11:57:07 -0300 Subject: [PATCH 10/27] fix(metrics): Use occurrence-count keys for stable dnd panel identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sortable key included the array index, so every reorder changed moved items' keys, causing React to unmount and remount panels. This lost local state and forced full ECharts re-initialization — the exact cost the placeholder optimization was designed to avoid. Use occurrence count instead of index to disambiguate duplicate queries, keeping keys stable across reorders. Co-Authored-By: Claude Opus 4.6 --- .../app/views/explore/metrics/metricsTab.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/static/app/views/explore/metrics/metricsTab.tsx b/static/app/views/explore/metrics/metricsTab.tsx index 382bcac595989f..e71a141161b517 100644 --- a/static/app/views/explore/metrics/metricsTab.tsx +++ b/static/app/views/explore/metrics/metricsTab.tsx @@ -213,14 +213,19 @@ function useSortableMetricQueries() { const reorderMetricQueries = useReorderMetricQueries(); const [isDragging, setIsDragging] = useState(false); - // Map from encoded query identity → stable unique ID. This correctly - // handles deletions and mid-list insertions (unlike an index-based array). - // Map from positional key (encoded query + index) → stable unique ID. - // Including the index ensures duplicate queries get distinct keys. + // Map from encoded query identity → stable unique ID. Uses occurrence + // count (not array index) to disambiguate duplicate queries, so keys + // remain stable across reorders. const idMapRef = useRef>(new Map()); const sortableItems = useMemo(() => { + const occurrences = new Map(); + const activeKeys = new Set(); const items = metricQueries.map((metricQuery, i) => { - const key = `${i}::${encodeMetricQueryParams(metricQuery)}`; + const encoded = encodeMetricQueryParams(metricQuery); + const occurrence = occurrences.get(encoded) ?? 0; + occurrences.set(encoded, occurrence + 1); + const key = `${encoded}#${occurrence}`; + activeKeys.add(key); let uid = idMapRef.current.get(key); if (!uid) { uid = uniqueId(); @@ -229,9 +234,6 @@ function useSortableMetricQueries() { return {id: i + 1, uniqueId: uid, metricQuery}; }); // Prune stale entries for queries that no longer exist. - const activeKeys = new Set( - items.map((item, i) => `${i}::${encodeMetricQueryParams(item.metricQuery)}`) - ); for (const key of idMapRef.current.keys()) { if (!activeKeys.has(key)) { idMapRef.current.delete(key); From 5ec4de630827e8cdb6083ff4db332838a8d7a97e Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 12:02:41 -0300 Subject: [PATCH 11/27] ref(metrics): Extract useSortableMetricQueries hook into its own file Move the sortable dnd hook out of metricsTab.tsx into a dedicated hooks file for better organization. Co-Authored-By: Claude Opus 4.6 --- .../hooks/useSortableMetricQueries.tsx | 79 ++++++++++++++++ .../app/views/explore/metrics/metricsTab.tsx | 90 +------------------ 2 files changed, 82 insertions(+), 87 deletions(-) create mode 100644 static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx 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..556b957664e928 --- /dev/null +++ b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx @@ -0,0 +1,79 @@ +import {useCallback, useMemo, useRef, 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 {uniqueId} from 'sentry/utils/guid'; +import {encodeMetricQueryParams} from 'sentry/views/explore/metrics/metricQuery'; +import { + useMultiMetricsQueryParams, + useReorderMetricQueries, +} from 'sentry/views/explore/metrics/multiMetricsQueryParams'; + +export function useSortableMetricQueries() { + const metricQueries = useMultiMetricsQueryParams(); + const reorderMetricQueries = useReorderMetricQueries(); + const [isDragging, setIsDragging] = useState(false); + + // Map from encoded query identity → stable unique ID. Uses occurrence + // count (not array index) to disambiguate duplicate queries, so keys + // remain stable across reorders. + const idMapRef = useRef>(new Map()); + const sortableItems = useMemo(() => { + const occurrences = new Map(); + const activeKeys = new Set(); + const items = metricQueries.map((metricQuery, i) => { + const encoded = encodeMetricQueryParams(metricQuery); + const occurrence = occurrences.get(encoded) ?? 0; + occurrences.set(encoded, occurrence + 1); + const key = `${encoded}#${occurrence}`; + activeKeys.add(key); + let uid = idMapRef.current.get(key); + if (!uid) { + uid = uniqueId(); + idMapRef.current.set(key, uid); + } + return {id: i + 1, uniqueId: uid, metricQuery}; + }); + // Prune stale entries for queries that no longer exist. + for (const key of idMapRef.current.keys()) { + if (!activeKeys.has(key)) { + idMapRef.current.delete(key); + } + } + return items; + }, [metricQueries]); + + 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.findIndex(({id}) => id === active.id); + const newIndex = sortableItems.findIndex(({id}) => id === over?.id); + if (oldIndex < 0 || newIndex < 0) { + return; + } + reorderMetricQueries(arrayMove([...metricQueries], 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/metricsTab.tsx b/static/app/views/explore/metrics/metricsTab.tsx index e71a141161b517..440ef02854ae93 100644 --- a/static/app/views/explore/metrics/metricsTab.tsx +++ b/static/app/views/explore/metrics/metricsTab.tsx @@ -1,19 +1,5 @@ -import {useCallback, useMemo, useRef, useState} from 'react'; -import type {DragEndEvent} from '@dnd-kit/core'; -import { - closestCenter, - DndContext, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; +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'; @@ -24,7 +10,6 @@ import {DatePageFilter} from 'sentry/components/pageFilters/date/datePageFilter' import {EnvironmentPageFilter} from 'sentry/components/pageFilters/environment/environmentPageFilter'; import {ProjectPageFilter} from 'sentry/components/pageFilters/project/projectPageFilter'; import {t} from 'sentry/locale'; -import {uniqueId} from 'sentry/utils/guid'; import {useChartInterval} from 'sentry/utils/useChartInterval'; import {useOrganization} from 'sentry/utils/useOrganization'; import {WidgetSyncContextProvider} from 'sentry/views/dashboards/contexts/widgetSyncContext'; @@ -37,9 +22,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 {encodeMetricQueryParams} from 'sentry/views/explore/metrics/metricQuery'; import { canUseMetricsEquations, canUseMetricsUIRefresh, @@ -51,7 +36,6 @@ import { MultiMetricsQueryParamsProvider, useAddMetricQuery, useMultiMetricsQueryParams, - useReorderMetricQueries, } from 'sentry/views/explore/metrics/multiMetricsQueryParams'; import { FilterBarWithSaveAsContainer, @@ -208,74 +192,6 @@ function MetricsQueryBuilderSection() { ); } -function useSortableMetricQueries() { - const metricQueries = useMultiMetricsQueryParams(); - const reorderMetricQueries = useReorderMetricQueries(); - const [isDragging, setIsDragging] = useState(false); - - // Map from encoded query identity → stable unique ID. Uses occurrence - // count (not array index) to disambiguate duplicate queries, so keys - // remain stable across reorders. - const idMapRef = useRef>(new Map()); - const sortableItems = useMemo(() => { - const occurrences = new Map(); - const activeKeys = new Set(); - const items = metricQueries.map((metricQuery, i) => { - const encoded = encodeMetricQueryParams(metricQuery); - const occurrence = occurrences.get(encoded) ?? 0; - occurrences.set(encoded, occurrence + 1); - const key = `${encoded}#${occurrence}`; - activeKeys.add(key); - let uid = idMapRef.current.get(key); - if (!uid) { - uid = uniqueId(); - idMapRef.current.set(key, uid); - } - return {id: i + 1, uniqueId: uid, metricQuery}; - }); - // Prune stale entries for queries that no longer exist. - for (const key of idMapRef.current.keys()) { - if (!activeKeys.has(key)) { - idMapRef.current.delete(key); - } - } - return items; - }, [metricQueries]); - - 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.findIndex(({id}) => id === active.id); - const newIndex = sortableItems.findIndex(({id}) => id === over?.id); - if (oldIndex < 0 || newIndex < 0) { - return; - } - reorderMetricQueries(arrayMove([...metricQueries], oldIndex, newIndex)); - } - }, - [sortableItems, metricQueries, reorderMetricQueries] - ); - - const onDragCancel = useCallback(() => { - setIsDragging(false); - }, []); - - return {sortableItems, sensors, onDragStart, onDragEnd, onDragCancel, isDragging}; -} - function MetricsTabBodySection() { const organization = useOrganization(); const metricQueries = useMultiMetricsQueryParams(); From d8ec7f66b5104dfb4ab2f29a0b5d560ad3bf9356 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 12:03:08 -0300 Subject: [PATCH 12/27] Just inline props --- static/app/views/explore/metrics/metricPanel/index.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index df7214d4af0eba..6b2bb75b0a425c 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -184,12 +184,13 @@ export function MetricPanel({ ); } -interface DnDPlaceholderProps { +function DnDPlaceholder({ + contentHeight, + isDragging, +}: { contentHeight: number | null; isDragging: boolean | undefined; -} - -function DnDPlaceholder({contentHeight, isDragging}: DnDPlaceholderProps) { +}) { return ( From 6e06de4ba19f9876ab016bf0147116c09d20a272 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 12:09:02 -0300 Subject: [PATCH 13/27] Tidy up sortable hook --- .../hooks/useSortableMetricQueries.tsx | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx index 556b957664e928..00ddebdb6107c8 100644 --- a/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx +++ b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx @@ -15,32 +15,39 @@ export function useSortableMetricQueries() { const reorderMetricQueries = useReorderMetricQueries(); const [isDragging, setIsDragging] = useState(false); - // Map from encoded query identity → stable unique ID. Uses occurrence + // Map from encoded query identity -> stable unique ID. Uses occurrence // count (not array index) to disambiguate duplicate queries, so keys // remain stable across reorders. const idMapRef = useRef>(new Map()); const sortableItems = useMemo(() => { - const occurrences = new Map(); const activeKeys = new Set(); + const occurrences = new Map(); + const items = metricQueries.map((metricQuery, i) => { const encoded = encodeMetricQueryParams(metricQuery); + const occurrence = occurrences.get(encoded) ?? 0; occurrences.set(encoded, occurrence + 1); + const key = `${encoded}#${occurrence}`; activeKeys.add(key); + let uid = idMapRef.current.get(key); if (!uid) { uid = uniqueId(); idMapRef.current.set(key, uid); } + return {id: i + 1, uniqueId: uid, metricQuery}; }); + // Prune stale entries for queries that no longer exist. - for (const key of idMapRef.current.keys()) { + idMapRef.current.keys().forEach(key => { if (!activeKeys.has(key)) { idMapRef.current.delete(key); } - } + }); + return items; }, [metricQueries]); @@ -62,9 +69,9 @@ export function useSortableMetricQueries() { if (active.id !== over?.id) { const oldIndex = sortableItems.findIndex(({id}) => id === active.id); const newIndex = sortableItems.findIndex(({id}) => id === over?.id); - if (oldIndex < 0 || newIndex < 0) { - return; - } + + if (oldIndex < 0 || newIndex < 0) return; + reorderMetricQueries(arrayMove([...metricQueries], oldIndex, newIndex)); } }, From 9783f38fd84f39f0fafd9f6c461d5681054136df Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 12:11:08 -0300 Subject: [PATCH 14/27] Match padding sizes --- static/app/views/explore/metrics/metricToolbar/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/explore/metrics/metricToolbar/index.tsx b/static/app/views/explore/metrics/metricToolbar/index.tsx index f372583cf69fe9..66b67230e216e6 100644 --- a/static/app/views/explore/metrics/metricToolbar/index.tsx +++ b/static/app/views/explore/metrics/metricToolbar/index.tsx @@ -75,7 +75,7 @@ export function MetricToolbar({ gap="md" columns={columns} data-test-id="metric-toolbar" - paddingLeft="md" + paddingLeft="lg" paddingRight="lg" paddingTop="md" > From 542cacf98b3d6ddfea9c1a19d90a4e4fb420999a Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 13:47:47 -0300 Subject: [PATCH 15/27] Render placeholder chart and table during dnd reorder --- .../explore/metrics/metricPanel/index.tsx | 21 +++++++++---------- .../explore/metrics/metricToolbar/index.tsx | 4 ++-- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index 6b2bb75b0a425c..923b9d53e3ed64 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -1,7 +1,7 @@ import {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'; @@ -191,17 +191,16 @@ function DnDPlaceholder({ contentHeight: number | null; isDragging: boolean | undefined; }) { + const placeholderHeight = contentHeight ? `${contentHeight}px` : '200px'; + return ( - - - {isDragging ? ( - - {t( - 'Charts are hidden while reordering. Too expensive to drag along for the ride.' - )} - - ) : null} - + + + + {isDragging ? {t('Charts are hidden while reordering.')} : null} + + + ); } diff --git a/static/app/views/explore/metrics/metricToolbar/index.tsx b/static/app/views/explore/metrics/metricToolbar/index.tsx index 66b67230e216e6..788b1bb4435bb6 100644 --- a/static/app/views/explore/metrics/metricToolbar/index.tsx +++ b/static/app/views/explore/metrics/metricToolbar/index.tsx @@ -75,8 +75,8 @@ export function MetricToolbar({ gap="md" columns={columns} data-test-id="metric-toolbar" - paddingLeft="lg" - paddingRight="lg" + paddingLeft="md" + paddingRight="md" paddingTop="md" > {dragListeners ? : null} From b95fdaaee3493b1138c37a84225def3522db5ff7 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 13:49:34 -0300 Subject: [PATCH 16/27] Fix dnd placeholder height to match measured content height --- .../app/views/explore/metrics/metricPanel/index.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index 923b9d53e3ed64..3af90d78439339 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -191,15 +191,16 @@ function DnDPlaceholder({ contentHeight: number | null; isDragging: boolean | undefined; }) { - const placeholderHeight = contentHeight ? `${contentHeight}px` : '200px'; - return ( - - - + + + {isDragging ? {t('Charts are hidden while reordering.')} : null} - + ); From aaadf749d9873739f24bf42deab7d953bf10942d Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 13:50:24 -0300 Subject: [PATCH 17/27] Use height prop --- static/app/views/explore/metrics/metricPanel/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index 3af90d78439339..12a5783787fa90 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -196,7 +196,7 @@ function DnDPlaceholder({ padding="lg md" style={contentHeight ? {height: `${contentHeight}px`} : undefined} > - + {isDragging ? {t('Charts are hidden while reordering.')} : null} From e204909d379ba3dc724398d6a3ccefb5de05c9fc Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 13:51:39 -0300 Subject: [PATCH 18/27] ref: Update DnD placeholder text to explain why charts are hidden Co-Authored-By: Claude Opus 4.6 --- static/app/views/explore/metrics/metricPanel/index.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index 12a5783787fa90..7921b62c1a54a6 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -198,7 +198,13 @@ function DnDPlaceholder({ > - {isDragging ? {t('Charts are hidden while reordering.')} : null} + {isDragging ? ( + + {t( + "Charts are hidden while reordering. They're too expensive to drag along for the ride." + )} + + ) : null} From d57d46b94a1f09741bc9d6e0348b30da11dcae58 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 14:23:11 -0300 Subject: [PATCH 19/27] feat(metrics): Render tab list and placeholders in DnD reorder state Show the Samples/Aggregates tab list with placeholder content on the table side during drag-and-drop reordering, giving a better visual indication of the panel layout. Co-Authored-By: Claude Opus 4.6 --- .../explore/metrics/metricInfoTabs/index.tsx | 32 +++++++++----- .../explore/metrics/metricPanel/index.tsx | 43 ++++++++++++------- 2 files changed, 50 insertions(+), 25 deletions(-) diff --git a/static/app/views/explore/metrics/metricInfoTabs/index.tsx b/static/app/views/explore/metrics/metricInfoTabs/index.tsx index a576e1bb62fc72..9d85080e65e155 100644 --- a/static/app/views/explore/metrics/metricInfoTabs/index.tsx +++ b/static/app/views/explore/metrics/metricInfoTabs/index.tsx @@ -52,17 +52,8 @@ export function MetricInfoTabs({ > {orientation === 'right' || visualize.visible ? ( - - - - {t('Samples')} - - - {t('Aggregates')} - - - {additionalActions} + ) : null} {visualize.visible && !contentsHidden ? ( @@ -86,3 +77,24 @@ export function MetricInfoTabs({ ); } + +export 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 7921b62c1a54a6..2de28217f940b8 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -21,6 +21,7 @@ import {useMetricAggregatesTable} from 'sentry/views/explore/metrics/hooks/useMe import {useMetricSamplesTable} from 'sentry/views/explore/metrics/hooks/useMetricSamplesTable'; import {useMetricTimeseries} from 'sentry/views/explore/metrics/hooks/useMetricTimeseries'; import {useTableOrientationControl} from 'sentry/views/explore/metrics/hooks/useOrientationControl'; +import {MetricInfoTabList} from 'sentry/views/explore/metrics/metricInfoTabs'; import {SideBySideOrientation} from 'sentry/views/explore/metrics/metricPanel/sideBySideOrientation'; import {StackedOrientation} from 'sentry/views/explore/metrics/metricPanel/stackedOrientation'; import {type TraceMetric} from 'sentry/views/explore/metrics/metricQuery'; @@ -192,21 +193,33 @@ function DnDPlaceholder({ isDragging: boolean | undefined; }) { return ( - - - - {isDragging ? ( - - {t( - "Charts are hidden while reordering. They're too expensive to drag along for the ride." - )} - - ) : null} - - + + + + + {isDragging ? ( + + {t( + "Charts are hidden while reordering. They're too expensive to drag along for the ride." + )} + + ) : null} + + + + + + + + {isDragging ? ( + + {t( + "Tables are hidden, they're also pretty expensive to drag along for the ride." + )} + + ) : null} + + ); From 0f102ec381bb44d9c14b74c21b87365e1fd6c402 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 14:29:21 -0300 Subject: [PATCH 20/27] perf(metrics): Use React Activity to preserve table state during DnD Wrap SideBySideOrientation in a React Activity so it stays mounted but hidden during drag-and-drop reordering. This prevents the table from refetching data when dragging ends. Co-Authored-By: Claude Opus 4.6 --- .../explore/metrics/metricPanel/index.tsx | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index 2de28217f940b8..c72153407e5e37 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -1,4 +1,4 @@ -import {useRef, useState} from 'react'; +import {Activity, Fragment, useRef, useState} from 'react'; import type {SyntheticListenerMap} from '@dnd-kit/core/dist/hooks/utilities'; import {Container, Grid, Stack} from '@sentry/scraps/layout'; @@ -124,30 +124,33 @@ export function MetricPanel({ /> {visualize.visible ? ( - isAnyDragging ? ( - - ) : ( - { - if (!isAnyDragging && containerRef) { - contentHeightRef.current = containerRef.offsetHeight ?? null; - } - }} - > - + {isAnyDragging ? ( + - - ) + ) : null} + + { + if (!isAnyDragging && containerRef) { + contentHeightRef.current = containerRef.offsetHeight ?? null; + } + }} + > + + + + ) : null}
@@ -193,7 +196,7 @@ function DnDPlaceholder({ isDragging: boolean | undefined; }) { return ( - + @@ -213,9 +216,7 @@ function DnDPlaceholder({ {isDragging ? ( - {t( - "Tables are hidden, they're also pretty expensive to drag along for the ride." - )} + {t("We gotta hide the tables too, they're also pretty expensive.")} ) : null} From d5eb0ae2f5c8b48628d912138d74ef45379f3648 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 14:48:19 -0300 Subject: [PATCH 21/27] fix(metrics): Fix iterator compatibility and simplify DnD placeholder Replace Map iterator .forEach() with for...of loop for broader JS engine compatibility. Simplify the DnD table placeholder to use a plain Placeholder instead of rendering MetricInfoTabList outside its required TabStateProvider context. Co-Authored-By: Claude Opus 4.6 --- .../explore/metrics/hooks/useSortableMetricQueries.tsx | 4 ++-- static/app/views/explore/metrics/metricPanel/index.tsx | 10 +++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx index 00ddebdb6107c8..e698b0e7b18e68 100644 --- a/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx +++ b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx @@ -42,11 +42,11 @@ export function useSortableMetricQueries() { }); // Prune stale entries for queries that no longer exist. - idMapRef.current.keys().forEach(key => { + for (const key of idMapRef.current.keys()) { if (!activeKeys.has(key)) { idMapRef.current.delete(key); } - }); + } return items; }, [metricQueries]); diff --git a/static/app/views/explore/metrics/metricPanel/index.tsx b/static/app/views/explore/metrics/metricPanel/index.tsx index c72153407e5e37..5d43d608246787 100644 --- a/static/app/views/explore/metrics/metricPanel/index.tsx +++ b/static/app/views/explore/metrics/metricPanel/index.tsx @@ -21,7 +21,6 @@ import {useMetricAggregatesTable} from 'sentry/views/explore/metrics/hooks/useMe import {useMetricSamplesTable} from 'sentry/views/explore/metrics/hooks/useMetricSamplesTable'; import {useMetricTimeseries} from 'sentry/views/explore/metrics/hooks/useMetricTimeseries'; import {useTableOrientationControl} from 'sentry/views/explore/metrics/hooks/useOrientationControl'; -import {MetricInfoTabList} from 'sentry/views/explore/metrics/metricInfoTabs'; import {SideBySideOrientation} from 'sentry/views/explore/metrics/metricPanel/sideBySideOrientation'; import {StackedOrientation} from 'sentry/views/explore/metrics/metricPanel/stackedOrientation'; import {type TraceMetric} from 'sentry/views/explore/metrics/metricQuery'; @@ -209,18 +208,15 @@ function DnDPlaceholder({ ) : null} - - - - - + + {isDragging ? ( {t("We gotta hide the tables too, they're also pretty expensive.")} ) : null} - + ); From 60a1e6c8e35f353a8f28b2992d2943718a770564 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 14:59:20 -0300 Subject: [PATCH 22/27] ref(metrics): Remove unused export from MetricInfoTabList MetricInfoTabList is only used internally within its module, so the export keyword is unnecessary. Co-Authored-By: Claude Opus 4.6 --- static/app/views/explore/metrics/metricInfoTabs/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/explore/metrics/metricInfoTabs/index.tsx b/static/app/views/explore/metrics/metricInfoTabs/index.tsx index 9d85080e65e155..b17f09b225ec3e 100644 --- a/static/app/views/explore/metrics/metricInfoTabs/index.tsx +++ b/static/app/views/explore/metrics/metricInfoTabs/index.tsx @@ -78,7 +78,7 @@ export function MetricInfoTabs({ ); } -export function MetricInfoTabList({ +function MetricInfoTabList({ orientation, contentsHidden, }: { From c1ecb53e40a87d3b2ba28ed54993eee4448a1113 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Fri, 10 Apr 2026 15:00:19 -0300 Subject: [PATCH 23/27] fix(metrics): Restore tab list and actions render order The tab list (Samples/Aggregates) should render before additionalActions in the Flex container so it appears on the left, with the PanelPositionSelector and HideContentButton on the right. Co-Authored-By: Claude Opus 4.6 --- static/app/views/explore/metrics/metricInfoTabs/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/explore/metrics/metricInfoTabs/index.tsx b/static/app/views/explore/metrics/metricInfoTabs/index.tsx index b17f09b225ec3e..1f70a8f9656eca 100644 --- a/static/app/views/explore/metrics/metricInfoTabs/index.tsx +++ b/static/app/views/explore/metrics/metricInfoTabs/index.tsx @@ -52,8 +52,8 @@ export function MetricInfoTabs({ > {orientation === 'right' || visualize.visible ? ( - {additionalActions} + {additionalActions} ) : null} {visualize.visible && !contentsHidden ? ( From 44810e457fe687e253b47e1c96ffb7afdc2e03ff Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Mon, 13 Apr 2026 15:18:56 -0300 Subject: [PATCH 24/27] fix(metrics): Keep labels stable during panel reorder Move stable label state alongside metric query reorder operations so drag-and-drop does not relabel toolbar badges by position. Add a regression test that verifies labels remain attached to query identity after reorder. Co-Authored-By: Codex --- .../hooks/useSortableMetricQueries.tsx | 6 +- .../explore/metrics/hooks/useStableLabels.tsx | 19 ++++++ .../metrics/multiMetricsQueryParams.spec.tsx | 63 +++++++++++++++++++ .../metrics/multiMetricsQueryParams.tsx | 11 +++- 4 files changed, 96 insertions(+), 3 deletions(-) diff --git a/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx index e698b0e7b18e68..6db29c9e5a7de7 100644 --- a/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx +++ b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx @@ -72,7 +72,11 @@ export function useSortableMetricQueries() { if (oldIndex < 0 || newIndex < 0) return; - reorderMetricQueries(arrayMove([...metricQueries], oldIndex, newIndex)); + reorderMetricQueries( + arrayMove([...metricQueries], oldIndex, newIndex), + oldIndex, + newIndex + ); } }, [sortableItems, metricQueries, reorderMetricQueries] 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/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 7d270702bc5c18..fc965e7b2cc891 100644 --- a/static/app/views/explore/metrics/multiMetricsQueryParams.tsx +++ b/static/app/views/explore/metrics/multiMetricsQueryParams.tsx @@ -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, @@ -257,9 +259,14 @@ export function useAddMetricQuery({ export function useReorderMetricQueries() { const location = useLocation(); const navigate = useNavigate(); + const {reorderLabels}: MultiMetricsQueryParamsContextValue = + useMultiMetricsQueryParamsContext(); return useCallback( - (reorderedQueries: BaseMetricQuery[]) => { + (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)) @@ -268,6 +275,6 @@ export function useReorderMetricQueries() { navigate(target); }, - [location, navigate] + [location, navigate, reorderLabels] ); } From 8f6defb2792f28417f675d182a2c6fe0ee7569eb Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Tue, 14 Apr 2026 10:33:10 -0300 Subject: [PATCH 25/27] ref(metrics): Use labels for sortable query IDs Replace the metrics drag-and-drop UUID bookkeeping with the stable query labels that now persist across query mutations. Keep the sortable panel IDs and React keys aligned with those labels, and add a focused hook test that covers duplicate-query reordering. Co-Authored-By: Codex --- .../hooks/useSortableMetricQueries.spec.tsx | 85 +++++++++++++++++++ .../hooks/useSortableMetricQueries.tsx | 41 ++------- .../metricPanel/sortableMetricPanel.tsx | 2 +- .../app/views/explore/metrics/metricsTab.tsx | 4 +- 4 files changed, 93 insertions(+), 39 deletions(-) create mode 100644 static/app/views/explore/metrics/hooks/useSortableMetricQueries.spec.tsx 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..139838023bdf67 --- /dev/null +++ b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.spec.tsx @@ -0,0 +1,85 @@ +import type {DragEndEvent} from '@dnd-kit/core'; + +import {act, renderHookWithProviders} from 'sentry-test/reactTestingLibrary'; + +import {useSortableMetricQueries} from 'sentry/views/explore/metrics/hooks/useSortableMetricQueries'; +import {MultiMetricsQueryParamsProvider} from 'sentry/views/explore/metrics/multiMetricsQueryParams'; +import {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('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 index 6db29c9e5a7de7..d8c0c7bd66227c 100644 --- a/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx +++ b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx @@ -1,10 +1,8 @@ -import {useCallback, useMemo, useRef, useState} from 'react'; +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 {uniqueId} from 'sentry/utils/guid'; -import {encodeMetricQueryParams} from 'sentry/views/explore/metrics/metricQuery'; import { useMultiMetricsQueryParams, useReorderMetricQueries, @@ -15,40 +13,11 @@ export function useSortableMetricQueries() { const reorderMetricQueries = useReorderMetricQueries(); const [isDragging, setIsDragging] = useState(false); - // Map from encoded query identity -> stable unique ID. Uses occurrence - // count (not array index) to disambiguate duplicate queries, so keys - // remain stable across reorders. - const idMapRef = useRef>(new Map()); const sortableItems = useMemo(() => { - const activeKeys = new Set(); - const occurrences = new Map(); - - const items = metricQueries.map((metricQuery, i) => { - const encoded = encodeMetricQueryParams(metricQuery); - - const occurrence = occurrences.get(encoded) ?? 0; - occurrences.set(encoded, occurrence + 1); - - const key = `${encoded}#${occurrence}`; - activeKeys.add(key); - - let uid = idMapRef.current.get(key); - if (!uid) { - uid = uniqueId(); - idMapRef.current.set(key, uid); - } - - return {id: i + 1, uniqueId: uid, metricQuery}; - }); - - // Prune stale entries for queries that no longer exist. - for (const key of idMapRef.current.keys()) { - if (!activeKeys.has(key)) { - idMapRef.current.delete(key); - } - } - - return items; + return metricQueries.map((metricQuery, index) => ({ + id: metricQuery.label ?? String(index), + metricQuery, + })); }, [metricQueries]); const sensors = useSensors( diff --git a/static/app/views/explore/metrics/metricPanel/sortableMetricPanel.tsx b/static/app/views/explore/metrics/metricPanel/sortableMetricPanel.tsx index da631609215391..a6fc1cabe58196 100644 --- a/static/app/views/explore/metrics/metricPanel/sortableMetricPanel.tsx +++ b/static/app/views/explore/metrics/metricPanel/sortableMetricPanel.tsx @@ -9,7 +9,7 @@ interface SortableMetricPanelProps { isAnyDragging: boolean; queryIndex: number; queryLabel: string; - sortableId: number; + sortableId: string; traceMetric: TraceMetric; references?: Set; } diff --git a/static/app/views/explore/metrics/metricsTab.tsx b/static/app/views/explore/metrics/metricsTab.tsx index 5b9174646894e6..6fc5a1f55b84ca 100644 --- a/static/app/views/explore/metrics/metricsTab.tsx +++ b/static/app/views/explore/metrics/metricsTab.tsx @@ -242,10 +242,10 @@ function MetricsTabBodySection() { items={sortableItems} strategy={verticalListSortingStrategy} > - {sortableItems.map(({id, uniqueId: uid, metricQuery}, index) => { + {sortableItems.map(({id, metricQuery}, index) => { return ( Date: Tue, 14 Apr 2026 10:54:20 -0300 Subject: [PATCH 26/27] fix(metrics): Scope sortable metric reordering Allow the sortable metrics hook to expose a filtered view while still reordering against the original query list. This keeps sectioned views stable and ignores drops that cross section boundaries. Co-Authored-By: Codex --- .../hooks/useSortableMetricQueries.spec.tsx | 131 +++++++++++++++++- .../hooks/useSortableMetricQueries.tsx | 32 +++-- 2 files changed, 153 insertions(+), 10 deletions(-) diff --git a/static/app/views/explore/metrics/hooks/useSortableMetricQueries.spec.tsx b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.spec.tsx index 139838023bdf67..b4787ef20a0320 100644 --- a/static/app/views/explore/metrics/hooks/useSortableMetricQueries.spec.tsx +++ b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.spec.tsx @@ -2,9 +2,15 @@ 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 {VisualizeFunction} from 'sentry/views/explore/queryParams/visualize'; +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}; @@ -46,6 +52,129 @@ describe('useSortableMetricQueries', () => { 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'}, diff --git a/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx index d8c0c7bd66227c..3113fb68b4b1ac 100644 --- a/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx +++ b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx @@ -3,22 +3,36 @@ 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'; -export function useSortableMetricQueries() { +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.map((metricQuery, index) => ({ - id: metricQuery.label ?? String(index), - metricQuery, - })); - }, [metricQueries]); + return metricQueries.flatMap((metricQuery, index) => + predicate?.(metricQuery) === false + ? [] + : [ + { + id: metricQuery.label ?? String(index), + metricQuery, + index, + }, + ] + ); + }, [metricQueries, predicate]); const sensors = useSensors( useSensor(PointerSensor), @@ -36,10 +50,10 @@ export function useSortableMetricQueries() { setIsDragging(false); const {active, over} = event; if (active.id !== over?.id) { - const oldIndex = sortableItems.findIndex(({id}) => id === active.id); - const newIndex = sortableItems.findIndex(({id}) => id === over?.id); + const oldIndex = sortableItems.find(({id}) => id === active.id)?.index; + const newIndex = sortableItems.find(({id}) => id === over?.id)?.index; - if (oldIndex < 0 || newIndex < 0) return; + if (oldIndex === undefined || newIndex === undefined) return; reorderMetricQueries( arrayMove([...metricQueries], oldIndex, newIndex), From 4cd78992fd88c27490d7e7e65e9b76141cdbffb8 Mon Sep 17 00:00:00 2001 From: nsdeschenes Date: Tue, 14 Apr 2026 10:55:34 -0300 Subject: [PATCH 27/27] feat(metrics): Separate aggregate and equation panels Render aggregate queries and equations in their own sortable sections on the refreshed metrics tab. This keeps each panel group visually distinct while preserving the drag state needed for section-local reordering. Co-Authored-By: Codex --- .../views/explore/metrics/metricsTab.spec.tsx | 64 +++++++++ .../app/views/explore/metrics/metricsTab.tsx | 130 +++++++++++++----- 2 files changed, 157 insertions(+), 37 deletions(-) diff --git a/static/app/views/explore/metrics/metricsTab.spec.tsx b/static/app/views/explore/metrics/metricsTab.spec.tsx index 8834423ad0595e..12f7168d8156d8 100644 --- a/static/app/views/explore/metrics/metricsTab.spec.tsx +++ b/static/app/views/explore/metrics/metricsTab.spec.tsx @@ -15,8 +15,13 @@ import { import type {DatePageFilterProps} from 'sentry/components/pageFilters/date/datePageFilter'; import {trackAnalytics} from 'sentry/utils/analytics'; +import {EQUATION_PREFIX} from 'sentry/utils/discover/fields'; import {MetricsTabContent} from 'sentry/views/explore/metrics/metricsTab'; import {MultiMetricsQueryParamsProvider} from 'sentry/views/explore/metrics/multiMetricsQueryParams'; +import { + VisualizeEquation, + VisualizeFunction, +} from 'sentry/views/explore/queryParams/visualize'; jest.mock('sentry/utils/analytics'); const trackAnalyticsMock = jest.mocked(trackAnalytics); @@ -613,6 +618,65 @@ describe('MetricsTabContent', () => { 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 6fc5a1f55b84ca..a09b9b01f52fc0 100644 --- a/static/app/views/explore/metrics/metricsTab.tsx +++ b/static/app/views/explore/metrics/metricsTab.tsx @@ -3,6 +3,7 @@ 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'; @@ -42,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 = { @@ -218,8 +220,19 @@ function MetricsTabBodySection() { isMetricOptionsEmpty, }); const references = useMetricReferences(); - const {sortableItems, sensors, onDragStart, onDragEnd, onDragCancel, isDragging} = - useSortableMetricQueries(); + 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 = @@ -231,41 +244,27 @@ function MetricsTabBodySection() { - - - {sortableItems.map(({id, metricQuery}, index) => { - return ( - - 1} - /> - - ); - })} - - + + {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};