chore: alex - drag n drop, connection line fix
This commit is contained in:
@@ -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 {
|
||||
|
||||
+183
-50
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user