Skip to content

Commit 66f191f

Browse files
nsdeschenesclaudeClaude Opus 4.6codex
authored
feat(metrics): Add drag-and-drop reordering to metric panels (#112671)
Users with multiple metric panels can now grab the drag handle in the toolbar and reorder panels. The new order is persisted to URL query params so it survives page reloads. During a drag, chart content is replaced with a lightweight placeholder to avoid expensive re-renders of ECharts instances. Key details: - Drag handle is hidden when there's only one panel - Stable sortable keys handle duplicate queries and mid-list insertions - Keyboard and screen reader accessibility via dnd-kit sensors and forwarded ARIA attributes - `useReorderMetricQueries` hook encodes the reordered list back into URL params Closes EXP-827 --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Claude Opus 4.6 <noreply@example.com> Co-authored-by: Codex <noreply@openai.com>
1 parent ba066a2 commit 66f191f

File tree

11 files changed

+729
-56
lines changed

11 files changed

+729
-56
lines changed
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import type {DragEndEvent} from '@dnd-kit/core';
2+
3+
import {act, renderHookWithProviders} from 'sentry-test/reactTestingLibrary';
4+
5+
import {EQUATION_PREFIX} from 'sentry/utils/discover/fields';
6+
import {useSortableMetricQueries} from 'sentry/views/explore/metrics/hooks/useSortableMetricQueries';
7+
import {MultiMetricsQueryParamsProvider} from 'sentry/views/explore/metrics/multiMetricsQueryParams';
8+
import {useMultiMetricsQueryParams} from 'sentry/views/explore/metrics/multiMetricsQueryParams';
9+
import {
10+
isVisualizeEquation,
11+
VisualizeEquation,
12+
VisualizeFunction,
13+
} from 'sentry/views/explore/queryParams/visualize';
14+
15+
function Wrapper({children}: {children: React.ReactNode}) {
16+
return <MultiMetricsQueryParamsProvider>{children}</MultiMetricsQueryParamsProvider>;
17+
}
18+
19+
describe('useSortableMetricQueries', () => {
20+
it('uses stable labels as sortable ids', () => {
21+
const {result} = renderHookWithProviders(useSortableMetricQueries, {
22+
additionalWrapper: Wrapper,
23+
initialRouterConfig: {
24+
location: {
25+
pathname: '/organizations/org-slug/explore/metrics/',
26+
query: {
27+
metric: [
28+
JSON.stringify({
29+
metric: {name: 'foo', type: 'counter'},
30+
query: '',
31+
aggregateFields: [
32+
new VisualizeFunction('sum(value,foo,counter,-)').serialize(),
33+
],
34+
aggregateSortBys: [],
35+
mode: 'samples',
36+
}),
37+
JSON.stringify({
38+
metric: {name: 'bar', type: 'counter'},
39+
query: '',
40+
aggregateFields: [
41+
new VisualizeFunction('sum(value,bar,counter,-)').serialize(),
42+
],
43+
aggregateSortBys: [],
44+
mode: 'samples',
45+
}),
46+
],
47+
},
48+
},
49+
},
50+
});
51+
52+
expect(result.current.sortableItems.map(({id}) => id)).toEqual(['A', 'B']);
53+
});
54+
55+
it('filters sortable items by query type and reorders within that section', () => {
56+
const {result} = renderHookWithProviders(
57+
() => {
58+
const sortable = useSortableMetricQueries({
59+
predicate: metricQuery =>
60+
!isVisualizeEquation(metricQuery.queryParams.visualizes[0]!),
61+
});
62+
const metricQueries = useMultiMetricsQueryParams();
63+
return {sortable, metricQueries};
64+
},
65+
{
66+
additionalWrapper: Wrapper,
67+
initialRouterConfig: {
68+
location: {
69+
pathname: '/organizations/org-slug/explore/metrics/',
70+
query: {
71+
metric: [
72+
JSON.stringify({
73+
metric: {name: 'foo', type: 'counter'},
74+
query: '',
75+
aggregateFields: [
76+
new VisualizeFunction('sum(value,foo,counter,-)').serialize(),
77+
],
78+
aggregateSortBys: [],
79+
mode: 'samples',
80+
}),
81+
JSON.stringify({
82+
metric: {name: 'bar', type: 'counter'},
83+
query: '',
84+
aggregateFields: [
85+
new VisualizeFunction('sum(value,bar,counter,-)').serialize(),
86+
],
87+
aggregateSortBys: [],
88+
mode: 'samples',
89+
}),
90+
JSON.stringify({
91+
metric: {name: '', type: ''},
92+
query: '',
93+
aggregateFields: [new VisualizeEquation(EQUATION_PREFIX).serialize()],
94+
aggregateSortBys: [],
95+
mode: 'samples',
96+
}),
97+
],
98+
},
99+
},
100+
},
101+
}
102+
);
103+
104+
expect(result.current.sortable.sortableItems.map(({id}) => id)).toEqual(['A', 'B']);
105+
expect(result.current.metricQueries.map(metricQuery => metricQuery.label)).toEqual([
106+
'A',
107+
'B',
108+
'ƒ1',
109+
]);
110+
111+
act(() => {
112+
result.current.sortable.onDragEnd({
113+
active: {id: 'B'},
114+
over: {id: 'A'},
115+
} as DragEndEvent);
116+
});
117+
118+
expect(result.current.metricQueries.map(metricQuery => metricQuery.label)).toEqual([
119+
'B',
120+
'A',
121+
'ƒ1',
122+
]);
123+
});
124+
125+
it('ignores drops outside the current section', () => {
126+
const {result} = renderHookWithProviders(
127+
() => {
128+
const sortable = useSortableMetricQueries({
129+
predicate: metricQuery =>
130+
!isVisualizeEquation(metricQuery.queryParams.visualizes[0]!),
131+
});
132+
const metricQueries = useMultiMetricsQueryParams();
133+
return {sortable, metricQueries};
134+
},
135+
{
136+
additionalWrapper: Wrapper,
137+
initialRouterConfig: {
138+
location: {
139+
pathname: '/organizations/org-slug/explore/metrics/',
140+
query: {
141+
metric: [
142+
JSON.stringify({
143+
metric: {name: 'foo', type: 'counter'},
144+
query: '',
145+
aggregateFields: [
146+
new VisualizeFunction('sum(value,foo,counter,-)').serialize(),
147+
],
148+
aggregateSortBys: [],
149+
mode: 'samples',
150+
}),
151+
JSON.stringify({
152+
metric: {name: '', type: ''},
153+
query: '',
154+
aggregateFields: [new VisualizeEquation(EQUATION_PREFIX).serialize()],
155+
aggregateSortBys: [],
156+
mode: 'samples',
157+
}),
158+
],
159+
},
160+
},
161+
},
162+
}
163+
);
164+
165+
act(() => {
166+
result.current.sortable.onDragEnd({
167+
active: {id: 'A'},
168+
over: {id: 'ƒ1'},
169+
} as DragEndEvent);
170+
});
171+
172+
expect(result.current.metricQueries.map(metricQuery => metricQuery.label)).toEqual([
173+
'A',
174+
'ƒ1',
175+
]);
176+
});
177+
178+
it('reorders duplicate queries by label ids', () => {
179+
const duplicateQuery = JSON.stringify({
180+
metric: {name: 'foo', type: 'counter'},
181+
query: '',
182+
aggregateFields: [new VisualizeFunction('sum(value,foo,counter,-)').serialize()],
183+
aggregateSortBys: [],
184+
mode: 'samples',
185+
});
186+
187+
const {result} = renderHookWithProviders(useSortableMetricQueries, {
188+
additionalWrapper: Wrapper,
189+
initialRouterConfig: {
190+
location: {
191+
pathname: '/organizations/org-slug/explore/metrics/',
192+
query: {
193+
metric: [duplicateQuery, duplicateQuery],
194+
},
195+
},
196+
},
197+
});
198+
199+
expect(
200+
result.current.sortableItems.map(({metricQuery}) => metricQuery.label)
201+
).toEqual(['A', 'B']);
202+
203+
act(() => {
204+
result.current.onDragEnd({
205+
active: {id: 'A'},
206+
over: {id: 'B'},
207+
} as DragEndEvent);
208+
});
209+
210+
expect(
211+
result.current.sortableItems.map(({metricQuery}) => metricQuery.label)
212+
).toEqual(['B', 'A']);
213+
});
214+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {useCallback, useMemo, useState} from 'react';
2+
import type {DragEndEvent} from '@dnd-kit/core';
3+
import {KeyboardSensor, PointerSensor, useSensor, useSensors} from '@dnd-kit/core';
4+
import {arrayMove, sortableKeyboardCoordinates} from '@dnd-kit/sortable';
5+
6+
import type {MetricQuery} from 'sentry/views/explore/metrics/metricQuery';
7+
import {
8+
useMultiMetricsQueryParams,
9+
useReorderMetricQueries,
10+
} from 'sentry/views/explore/metrics/multiMetricsQueryParams';
11+
12+
interface UseSortableMetricQueriesOptions {
13+
predicate?: (metricQuery: MetricQuery) => boolean;
14+
}
15+
16+
export function useSortableMetricQueries({
17+
predicate,
18+
}: UseSortableMetricQueriesOptions = {}) {
19+
const metricQueries = useMultiMetricsQueryParams();
20+
const reorderMetricQueries = useReorderMetricQueries();
21+
const [isDragging, setIsDragging] = useState(false);
22+
23+
const sortableItems = useMemo(() => {
24+
return metricQueries.flatMap((metricQuery, index) =>
25+
predicate?.(metricQuery) === false
26+
? []
27+
: [
28+
{
29+
id: metricQuery.label ?? String(index),
30+
metricQuery,
31+
index,
32+
},
33+
]
34+
);
35+
}, [metricQueries, predicate]);
36+
37+
const sensors = useSensors(
38+
useSensor(PointerSensor),
39+
useSensor(KeyboardSensor, {
40+
coordinateGetter: sortableKeyboardCoordinates,
41+
})
42+
);
43+
44+
const onDragStart = useCallback(() => {
45+
setIsDragging(true);
46+
}, []);
47+
48+
const onDragEnd = useCallback(
49+
(event: DragEndEvent) => {
50+
setIsDragging(false);
51+
const {active, over} = event;
52+
if (active.id !== over?.id) {
53+
const oldIndex = sortableItems.find(({id}) => id === active.id)?.index;
54+
const newIndex = sortableItems.find(({id}) => id === over?.id)?.index;
55+
56+
if (oldIndex === undefined || newIndex === undefined) return;
57+
58+
reorderMetricQueries(
59+
arrayMove([...metricQueries], oldIndex, newIndex),
60+
oldIndex,
61+
newIndex
62+
);
63+
}
64+
},
65+
[sortableItems, metricQueries, reorderMetricQueries]
66+
);
67+
68+
const onDragCancel = useCallback(() => {
69+
setIsDragging(false);
70+
}, []);
71+
72+
return {sortableItems, sensors, onDragStart, onDragEnd, onDragCancel, isDragging};
73+
}

static/app/views/explore/metrics/hooks/useStableLabels.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,25 @@ export function useStableLabels(queries: BaseMetricQuery[]) {
9595
remove(position: number) {
9696
labelsRef.current = labelsRef.current.filter((_, j) => j !== position);
9797
},
98+
move(from: number, to: number) {
99+
if (
100+
from === to ||
101+
from < 0 ||
102+
to < 0 ||
103+
from >= labelsRef.current.length ||
104+
to >= labelsRef.current.length
105+
) {
106+
return;
107+
}
108+
109+
const next = [...labelsRef.current];
110+
const [label] = next.splice(from, 1);
111+
if (!label) {
112+
return;
113+
}
114+
next.splice(to, 0, label);
115+
labelsRef.current = next;
116+
},
98117
}),
99118
[]
100119
);

static/app/views/explore/metrics/metricInfoTabs/index.tsx

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,16 +52,7 @@ export function MetricInfoTabs({
5252
>
5353
{orientation === 'right' || visualize.visible ? (
5454
<Flex direction="row" justify="between" align="center" paddingRight="xl">
55-
<TabListWrapper orientation={orientation}>
56-
<TabList variant="floating">
57-
<TabList.Item key={Mode.SAMPLES} disabled={contentsHidden}>
58-
{t('Samples')}
59-
</TabList.Item>
60-
<TabList.Item key={Mode.AGGREGATE} disabled={contentsHidden}>
61-
{t('Aggregates')}
62-
</TabList.Item>
63-
</TabList>
64-
</TabListWrapper>
55+
<MetricInfoTabList orientation={orientation} contentsHidden={contentsHidden} />
6556
{additionalActions}
6657
</Flex>
6758
) : null}
@@ -86,3 +77,24 @@ export function MetricInfoTabs({
8677
</TabStateProvider>
8778
);
8879
}
80+
81+
function MetricInfoTabList({
82+
orientation,
83+
contentsHidden,
84+
}: {
85+
orientation: TableOrientation;
86+
contentsHidden?: boolean;
87+
}) {
88+
return (
89+
<TabListWrapper orientation={orientation}>
90+
<TabList variant="floating">
91+
<TabList.Item key={Mode.SAMPLES} disabled={contentsHidden}>
92+
{t('Samples')}
93+
</TabList.Item>
94+
<TabList.Item key={Mode.AGGREGATE} disabled={contentsHidden}>
95+
{t('Aggregates')}
96+
</TabList.Item>
97+
</TabList>
98+
</TabListWrapper>
99+
);
100+
}

0 commit comments

Comments
 (0)