Fix drag-to-tabs edge cases and add RowsLayoutManager drop target

- Fix closure bug in _stopDraggingSync where drop targets were nulled before setTimeout callback
- Add RowsLayoutManager as a drop target when empty (allows dropping grid items)
- Fix dropped row not being added when converting grid layout to RowsLayoutManager
- Remove visual drop target outline from RowsLayoutManager
- Properly reset isDropTarget state after drag operations complete
This commit is contained in:
oscarkilhed
2026-01-05 09:22:08 +01:00
parent 2873009b47
commit 381a67243d
4 changed files with 65 additions and 6 deletions
@@ -128,18 +128,21 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
private _stopDraggingSync(_evt: PointerEvent) {
const gridItem = this.state.draggingGridItem?.resolve();
const wasDetached = this._itemDetachedFromSource;
// Capture these before cleanup since setTimeout runs after cleanup
const sourceDropTarget = this._sourceDropTarget;
const lastDropTarget = this._lastDropTarget;
// Handle cross-layout or cross-tab drop
if (this._sourceDropTarget !== this._lastDropTarget || wasDetached) {
if (sourceDropTarget !== lastDropTarget || wasDetached) {
// Wrapped in setTimeout to ensure that any event handlers are called
// Useful for allowing react-grid-layout to remove placeholders, etc.
setTimeout(() => {
if (gridItem) {
// Only remove from source if not already detached during tab switch
if (!wasDetached) {
this._sourceDropTarget?.draggedGridItemOutside?.(gridItem);
sourceDropTarget?.draggedGridItemOutside?.(gridItem);
}
this._lastDropTarget?.draggedGridItemInside?.(gridItem);
lastDropTarget?.draggedGridItemInside?.(gridItem);
} else {
const warningMessage = 'No grid item to drag';
console.warn(warningMessage);
@@ -153,6 +156,9 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
this._clearTabActivationTimer();
this._clearDragPreview();
this._lastDropTarget?.setIsDropTarget?.(false);
this._lastDropTarget = null;
this._sourceDropTarget = null;
this._itemDetachedFromSource = false;
this.setState({ draggingGridItem: undefined, sourceTabKey: undefined, hoverTabKey: undefined });
}
@@ -244,6 +250,9 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
document.body.removeEventListener('pointerup', this._onRowDragPointerUp);
this._clearTabActivationTimer();
this._clearDragPreview();
this._lastDropTarget?.setIsDropTarget?.(false);
this._lastDropTarget = null;
this._sourceDropTarget = null;
this._itemDetachedFromSource = false;
this._sourceRowsLayout = null;
this._rowOffsetCaptured = false;
@@ -2,6 +2,7 @@ import { t } from '@grafana/i18n';
import {
sceneGraph,
SceneGridItemLike,
SceneGridLayout,
SceneGridRow,
SceneObject,
SceneObjectBase,
@@ -13,6 +14,7 @@ import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboa
import { dashboardEditActions, ObjectsReorderedOnCanvasEvent } from '../../edit-pane/shared';
import { serializeRowsLayout } from '../../serialization/layoutSerializers/RowsLayoutSerializer';
import { getDashboardSceneFor } from '../../utils/utils';
import { AutoGridItem } from '../layout-auto-grid/AutoGridItem';
import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager';
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
@@ -22,6 +24,7 @@ import { findAllGridTypes } from '../layouts-shared/findAllGridTypes';
import { getRowFromClipboard } from '../layouts-shared/paste';
import { showConvertMixedGridsModal, showUngroupConfirmation } from '../layouts-shared/ungroupConfirmation';
import { generateUniqueTitle, ungroupLayout, GridLayoutType, mapIdToGridLayoutType } from '../layouts-shared/utils';
import { DashboardDropTarget } from '../types/DashboardDropTarget';
import { isDashboardLayoutGrid } from '../types/DashboardLayoutGrid';
import { DashboardLayoutGroup, isDashboardLayoutGroup } from '../types/DashboardLayoutGroup';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
@@ -33,11 +36,16 @@ import { RowLayoutManagerRenderer } from './RowsLayoutManagerRenderer';
interface RowsLayoutManagerState extends SceneObjectState {
rows: RowItem[];
isDropTarget?: boolean;
}
export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> implements DashboardLayoutGroup {
export class RowsLayoutManager
extends SceneObjectBase<RowsLayoutManagerState>
implements DashboardLayoutGroup, DashboardDropTarget
{
public static Component = RowLayoutManagerRenderer;
public readonly isDashboardLayoutManager = true;
public readonly isDashboardDropTarget = true as const;
public static readonly descriptor: LayoutRegistryItem = {
get name() {
@@ -62,6 +70,38 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
this.state.rows[0]?.getLayout().addPanel(vizPanel);
}
public setIsDropTarget(isDropTarget: boolean): void {
this.setState({ isDropTarget });
}
public draggedGridItemInside(gridItem: SceneGridItemLike): void {
// Create a new row with a DefaultGridLayoutManager and add the grid item to it
const newLayout = new DefaultGridLayoutManager({
grid: new SceneGridLayout({ children: [], isDraggable: true, isResizable: true }),
});
const newRow = new RowItem({
title: t('dashboard.rows-layout.new-row-title', 'New row'),
layout: newLayout,
collapse: false,
});
// Convert AutoGridItem to DashboardGridItem if needed
if (gridItem instanceof AutoGridItem) {
const vizPanel = gridItem.state.body;
const newGridItem = new DashboardGridItem({
body: vizPanel.clone(),
width: 12,
height: 8,
});
newLayout.addGridItem(newGridItem);
} else if (gridItem instanceof DashboardGridItem) {
newLayout.addGridItem(gridItem);
}
// Add the row to this layout
this.setState({ rows: [...this.state.rows, newRow], isDropTarget: false });
}
public getVizPanels(): VizPanel[] {
const panels: VizPanel[] = [];
@@ -25,6 +25,9 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
const soloPanelContext = useSoloPanelContext();
const orchestrator = getLayoutOrchestratorFor(model);
// Only act as a drop target when empty (no rows)
const showAsDropTarget = rows.length === 0;
const handleBeforeCapture = useCallback(
(before: BeforeCapture) => {
const row = rows.find((r) => r.state.key === before.draggableId);
@@ -75,7 +78,12 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
>
<Droppable droppableId={key!} direction="vertical">
{(dropProvided) => (
<div className={styles.wrapper} ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
<div
className={styles.wrapper}
ref={dropProvided.innerRef}
{...dropProvided.droppableProps}
{...(showAsDropTarget ? { 'data-dashboard-drop-target-key': key } : {})}
>
{rows.map((row) => (
<RowWrapper row={row} manager={model} key={row.state.key!} />
))}
@@ -264,8 +264,10 @@ export class TabItem
rowsLayout = new RowsLayoutManager({ rows: [row] });
} else {
// Convert existing layout and add the dropped row
// Use direct state update instead of addNewRow because the rowsLayout
// isn't connected to the scene yet, so dashboardEditActions won't work
rowsLayout = RowsLayoutManager.createFromLayout(currentLayout);
rowsLayout.addNewRow(row);
rowsLayout.setState({ rows: [...rowsLayout.state.rows, row] });
}
// Clear the parent reference from the old layout