chore: alex - drag n drop, connection line fix

This commit is contained in:
Alex Spencer
2025-12-02 17:19:40 -08:00
parent 65ef473c1f
commit 0425e940dd
3 changed files with 269 additions and 78 deletions
@@ -11,57 +11,84 @@ interface Connection {
interface ConnectionLinesProps {
connections: Connection[];
isDragging?: boolean;
}
export function ConnectionLines({ connections }: ConnectionLinesProps) {
export function ConnectionLines({ connections, isDragging = false }: ConnectionLinesProps) {
const styles = useStyles2(getStyles);
const [positions, setPositions] = useState<Map<string, DOMRect>>(new Map());
const containerRef = useRef<SVGSVGElement>(null);
useLayoutEffect(() => {
let rafId: number | null = null;
let isUpdating = false;
const updatePositions = () => {
const newPositions = new Map<string, DOMRect>();
const cards = document.querySelectorAll('[data-card-id]');
if (isUpdating) {
return;
}
cards.forEach((element) => {
const cardId = element.getAttribute('data-card-id');
if (cardId) {
newPositions.set(cardId, element.getBoundingClientRect());
}
isUpdating = true;
rafId = requestAnimationFrame(() => {
const newPositions = new Map<string, DOMRect>();
const cards = document.querySelectorAll('[data-card-id]');
cards.forEach((element) => {
const cardId = element.getAttribute('data-card-id');
if (cardId) {
newPositions.set(cardId, element.getBoundingClientRect());
}
});
setPositions(newPositions);
isUpdating = false;
});
setPositions(newPositions);
};
const timeoutId = setTimeout(updatePositions, 100);
window.addEventListener('resize', updatePositions);
// Initial update and update when drag ends
if (!isDragging) {
updatePositions();
}
const container = containerRef.current?.parentElement;
if (container) {
const scrollContainer = container.querySelector('[data-scrollcontainer]');
scrollContainer?.addEventListener('scroll', updatePositions);
const observer = new MutationObserver(() => setTimeout(updatePositions, 50));
// Only observe mutations when not dragging (for card add/remove/reorder)
let mutationTimeout: number | null = null;
const observer = new MutationObserver(() => {
if (isDragging) {
return;
}
if (mutationTimeout) {
clearTimeout(mutationTimeout);
}
mutationTimeout = window.setTimeout(updatePositions, 100);
});
observer.observe(container, { childList: true, subtree: true });
// Track container resize (from splitter drag)
const resizeObserver = new ResizeObserver(updatePositions);
resizeObserver.observe(container);
return () => {
clearTimeout(timeoutId);
window.removeEventListener('resize', updatePositions);
scrollContainer?.removeEventListener('scroll', updatePositions);
if (rafId) {
cancelAnimationFrame(rafId);
}
if (mutationTimeout) {
clearTimeout(mutationTimeout);
}
observer.disconnect();
resizeObserver.disconnect();
};
}
return () => {
clearTimeout(timeoutId);
window.removeEventListener('resize', updatePositions);
if (rafId) {
cancelAnimationFrame(rafId);
}
};
}, [connections]);
}, [connections, isDragging]);
// Get container rect from the SVG's parent (contentWrapper)
const containerRect = containerRef.current?.parentElement?.getBoundingClientRect();
if (!containerRect || connections.length === 0) {
@@ -79,7 +106,7 @@ export function ConnectionLines({ connections }: ConnectionLinesProps) {
refIds.add(to);
});
const swimlaneX = containerRect.width - 24;
const swimlaneX = containerRect.width - 40; // Adjusted for increased right padding (64px)
const cardPositions: number[] = [];
refIds.forEach((refId) => {
@@ -118,8 +145,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
right: 0,
bottom: 0,
pointerEvents: 'none',
zIndex: 10,
overflow: 'visible',
@@ -430,6 +430,35 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
[tabs]
);
const handleReorderDataSources = useCallback(
(startIndex: number, endIndex: number) => {
if (queryRunner) {
const queries = queryRunner.state.queries || [];
const newQueries = Array.from(queries);
const [removed] = newQueries.splice(startIndex, 1);
newQueries.splice(endIndex, 0, removed);
queryRunner.setState({ queries: newQueries });
}
},
[queryRunner]
);
const handleReorderTransforms = useCallback(
(startIndex: number, endIndex: number) => {
const transformsTab = tabs.find((t): t is PanelDataTransformationsTab => t.tabId === TabId.Transformations);
if (transformsTab) {
const transformations = (transformsTab.getDataTransformer().state.transformations || []).filter(
isDataTransformerConfig
);
const newTransformations = Array.from(transformations);
const [removed] = newTransformations.splice(startIndex, 1);
newTransformations.splice(endIndex, 0, removed);
transformsTab.onChangeTransformations(newTransformations);
}
},
[tabs]
);
// Get data for transformations drawer
const sourceData = queryRunner?.useState();
const series = sourceData?.data?.series || [];
@@ -462,6 +491,8 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
onToggleExpressionVisibility={handleToggleExpressionVisibility}
onRemoveTransform={handleRemoveTransform}
onToggleTransformVisibility={handleToggleTransformVisibility}
onReorderDataSources={handleReorderDataSources}
onReorderTransforms={handleReorderTransforms}
/>
</div>
<div
@@ -497,9 +528,9 @@ export function shouldShowAlertingTab(pluginId: string) {
return isGraph || isTimeseries;
}
// Left pane sizing: cards grow from 180px-300px, plus content padding (32px + 48px = 80px)
const LEFT_PANE_MIN = 180 + 80; // 260px (180px card min + 80px padding)
const LEFT_PANE_MAX = 300 + 80; // 380px (300px card max + 80px padding)
// Left pane sizing: cards grow from 180px-300px, plus content padding (48px + 64px = 112px)
const LEFT_PANE_MIN = 180 + 112; // 292px (180px card min + 112px padding)
const LEFT_PANE_MAX = 300 + 112; // 412px (300px card max + 112px padding)
function getStyles(theme: GrafanaTheme2) {
return {
@@ -1,5 +1,6 @@
import { css } from '@emotion/css';
import { memo, useMemo } from 'react';
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd';
import { memo, useMemo, useState } from 'react';
import { DataTransformerConfig, GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
@@ -35,6 +36,8 @@ interface QueryTransformListProps {
onToggleExpressionVisibility?: (index: number) => void;
onRemoveTransform?: (index: number) => void;
onToggleTransformVisibility?: (index: number) => void;
onReorderDataSources?: (startIndex: number, endIndex: number) => void;
onReorderTransforms?: (startIndex: number, endIndex: number) => void;
}
export const QueryTransformList = memo(
@@ -55,8 +58,43 @@ export const QueryTransformList = memo(
onToggleExpressionVisibility,
onRemoveTransform,
onToggleTransformVisibility,
onReorderDataSources,
onReorderTransforms,
}: QueryTransformListProps) => {
const styles = useStyles2(getStyles);
const [isDragging, setIsDragging] = useState(false);
const onDragStart = () => {
setIsDragging(true);
};
const onDragEnd = (result: DropResult) => {
// Defer state updates until after drop animation
setTimeout(() => {
setIsDragging(false);
if (!result.destination) {
return;
}
const startIndex = result.source.index;
const endIndex = result.destination.index;
if (startIndex === endIndex) {
return;
}
// Handle reordering based on droppable ID
if (result.source.droppableId === 'data-sources' && result.destination.droppableId === 'data-sources') {
onReorderDataSources?.(startIndex, endIndex);
} else if (
result.source.droppableId === 'transformations' &&
result.destination.droppableId === 'transformations'
) {
onReorderTransforms?.(startIndex, endIndex);
}
}, 0);
};
const allConnections = useMemo(() => {
const conns: Array<{ from: string; to: string }> = [];
@@ -152,57 +190,119 @@ export const QueryTransformList = memo(
</span>
</Stack>
</div>
<ConnectionLines connections={visibleConnections} />
<div className={styles.scrollWrapper}>
<ScrollContainer data-scrollcontainer>
<div className={styles.content}>
<Stack direction="column" gap={3}>
{/* Data Sources Section (Queries + Expressions) */}
{dataSourceItems.length > 0 && (
<Stack direction="column" gap={2}>
<div className={styles.sectionLabel}>
{t('dashboard-scene.query-transform-list.queries-expressions', 'Queries & Expressions')}
</div>
{dataSourceItems.map((item) => (
<QueryTransformCard
key={item.id}
item={item.data}
type={item.type}
index={item.index}
isSelected={selectedId === item.id}
onClick={() => onSelect(item.id)}
{...getHandlers(item)}
/>
))}
</Stack>
)}
<div className={styles.contentWrapper}>
<ConnectionLines connections={visibleConnections} isDragging={isDragging} />
<DragDropContext onDragStart={onDragStart} onDragEnd={onDragEnd}>
<div className={styles.content}>
<Stack direction="column" gap={3}>
{/* Data Sources Section (Queries + Expressions) */}
{dataSourceItems.length > 0 && (
<Stack direction="column" gap={2}>
<div className={styles.sectionLabel}>
{t('dashboard-scene.query-transform-list.queries-expressions', 'Queries & Expressions')}
</div>
<Droppable droppableId="data-sources">
{(provided, snapshot) => {
// Check if dragging from transformations section
const isDraggingFromOtherSection =
isDragging && snapshot.draggingFromThisWith === null && snapshot.isDraggingOver;
{/* Transformations Section */}
{transformItems.length > 0 && (
<Stack direction="column" gap={2}>
<div className={styles.sectionLabel}>
{t('dashboard-scene.query-transform-list.transformations', 'Transformations')}
</div>
{transformItems.map((item) => (
<QueryTransformCard
key={item.id}
item={item.data}
type={item.type}
index={item.index}
isSelected={selectedId === item.id}
onClick={() => onSelect(item.id)}
{...getHandlers(item)}
/>
))}
</Stack>
)}
return (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={isDraggingFromOtherSection ? styles.droppableInvalid : undefined}
>
<Stack direction="column" gap={2}>
{dataSourceItems.map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={snapshot.isDragging ? styles.dragging : undefined}
>
<QueryTransformCard
item={item.data}
type={item.type}
index={item.index}
isSelected={selectedId === item.id}
onClick={() => onSelect(item.id)}
{...getHandlers(item)}
/>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</Stack>
</div>
);
}}
</Droppable>
</Stack>
)}
<AddDataItemMenu
onAddQuery={onAddQuery}
onAddTransform={onAddTransform}
onAddExpression={onAddExpression}
/>
</Stack>
{/* Transformations Section */}
{transformItems.length > 0 && (
<Stack direction="column" gap={2}>
<div className={styles.sectionLabel}>
{t('dashboard-scene.query-transform-list.transformations', 'Transformations')}
</div>
<Droppable droppableId="transformations">
{(provided, snapshot) => {
// Check if dragging from data sources section
const isDraggingFromOtherSection =
isDragging && snapshot.draggingFromThisWith === null && snapshot.isDraggingOver;
return (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={isDraggingFromOtherSection ? styles.droppableInvalid : undefined}
>
<Stack direction="column" gap={2}>
{transformItems.map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={snapshot.isDragging ? styles.dragging : undefined}
>
<QueryTransformCard
item={item.data}
type={item.type}
index={item.index}
isSelected={selectedId === item.id}
onClick={() => onSelect(item.id)}
{...getHandlers(item)}
/>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</Stack>
</div>
);
}}
</Droppable>
</Stack>
)}
<AddDataItemMenu
onAddQuery={onAddQuery}
onAddTransform={onAddTransform}
onAddExpression={onAddExpression}
/>
</Stack>
</div>
</DragDropContext>
</div>
</ScrollContainer>
</div>
@@ -270,7 +370,7 @@ const getStyles = (theme: GrafanaTheme2) => {
gap: theme.spacing(1),
marginLeft: theme.spacing(-2),
marginRight: theme.spacing(-2),
'&::before, &::after': {
'&::after': {
content: '""',
flex: 1,
height: '1px',
@@ -280,10 +380,43 @@ const getStyles = (theme: GrafanaTheme2) => {
scrollWrapper: css({
flex: 1,
minHeight: 0,
}),
contentWrapper: css({
position: 'relative',
minHeight: '100%',
}),
content: css({
padding: `${theme.spacing(2)} ${theme.spacing(6)} ${theme.spacing(4)} ${theme.spacing(4)}`,
padding: `${theme.spacing(2)} ${theme.spacing(8)} ${theme.spacing(4)} ${theme.spacing(6)}`,
position: 'relative',
}),
dragging: css({
opacity: 0.8,
cursor: 'grabbing !important',
// Use GPU-accelerated properties only
willChange: 'transform',
}),
droppableActive: css({
// Minimal styling for performance
}),
droppableInvalid: css({
position: 'relative',
cursor: 'not-allowed',
'&::after': {
content: '""',
position: 'absolute',
top: -4,
left: -4,
right: -4,
bottom: -4,
background: theme.colors.error.transparent,
borderRadius: theme.shape.radius.default,
pointerEvents: 'none',
zIndex: 0,
},
'& > *': {
position: 'relative',
zIndex: 1,
},
}),
footer: css({
...barBase,