Compare commits

...

13 Commits

Author SHA1 Message Date
oscarkilhed ccc52094a9 fix lint 2026-01-14 08:07:41 +01:00
oscarkilhed 47bb27a977 Dashboards: Fix react-grid-layout placeholder stuck when dragging panel across rows
Toggle isDraggable off/on when removing a panel from SceneGridLayout to force
react-grid-layout to exit drag mode and clear its internal state. This prevents
the placeholder from remaining stuck when dragging panels from a custom grid
inside a row to another layout.
2026-01-13 16:14:00 +01:00
oscarkilhed d599a2b125 Dashboards: Fix drag-to-tabs edge cases and improve drop behavior
- Use capture phase for pointerup listeners to handle tab header stopPropagation
- Drop items into current tab when released on tab header after cross-tab drag
- Handle RowsLayoutManager in TabItem.draggedGridItemInside (add to first row)
- Clear tab activation timer on row drag pointerup to prevent late tab switches
- Add tests for cross-tab drag scenarios
2026-01-13 13:03:33 +01:00
oscarkilhed 9992da3cc8 Refactor dashboard drop target attributes to use constants for improved maintainability
- Introduced constants for data attributes used to identify auto grid items and dashboard layout elements as drop targets.
- Updated references throughout the AutoGridLayout, AutoGridItemRenderer, DashboardLayoutOrchestrator, and other related components to utilize the new constants.
- This change enhances code readability and reduces the risk of errors related to hardcoded strings.
2026-01-13 09:07:55 +01:00
oscarkilhed 5256a5e83e remove dead code 2026-01-09 08:49:59 +01:00
oscarkilhed 337560a993 Merge branch 'main' into oscark/dragging-to-tabs-attempt-2 2026-01-08 14:44:05 +01:00
oscarkilhed ad10039d41 Add tests for layout orchestrator logic and clean up unecessary fallback 2026-01-08 14:43:18 +01:00
oscarkilhed 81880ffb2a i18n 2026-01-08 09:38:38 +01:00
oscarkilhed 792a26800b Remove panel selection during drag that opens edit pane
The code was selecting the panel after the drag distance threshold was reached,
which caused the edit pane to open during dragging. This was unintended behavior.
2026-01-08 09:31:11 +01:00
oscarkilhed 7ea4dd3744 Fix flickering when dragging AutoGrid items between grids
- Add isDroppedElsewhere() method to orchestrator for cross-layout detection
- Delay clearing draggingKey until item is moved to prevent source grid flicker
- Keep dropPosition/isDropTarget until draggedGridItemInside clears them
- Add endExternalDrag() to properly clean up CSS vars and draggingKey together
- Track container position to compensate for layout shifts during drag
2026-01-08 09:22:09 +01:00
oscarkilhed b444e42f05 Add visual placeholder for AutoGrid cross-grid drops
- Extend DashboardDropTarget interface with setDropPosition and position parameter
- Make AutoGridLayoutManager implement DashboardDropTarget with placeholder support
- Add placeholder rendering in AutoGridLayoutRenderer at dropPosition
- Track hover position in orchestrator with left/right half detection for precise placement
- Prevent flickering by tracking last hovered item key
- Add draggedGridItemOutside to AutoGridLayoutManager to remove items from source
- Clear row parent reference before adding to new layout in acceptDroppedRow
2026-01-07 10:25:33 +01:00
oscarkilhed 381a67243d 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
2026-01-05 09:22:08 +01:00
oscarkilhed 2873009b47 Attempt to drag to other tabs by creating a draggable preview 2026-01-02 13:53:37 +01:00
14 changed files with 1491 additions and 74 deletions
@@ -0,0 +1,508 @@
import { VizPanel } from '@grafana/scenes';
import { DashboardLayoutOrchestrator } from './DashboardLayoutOrchestrator';
import { DashboardScene } from './DashboardScene';
import { AutoGridItem } from './layout-auto-grid/AutoGridItem';
import { AutoGridLayout } from './layout-auto-grid/AutoGridLayout';
import { AutoGridLayoutManager } from './layout-auto-grid/AutoGridLayoutManager';
import { DashboardGridItem } from './layout-default/DashboardGridItem';
import { TabItem } from './layout-tabs/TabItem';
import { TabsLayoutManager } from './layout-tabs/TabsLayoutManager';
describe('DashboardLayoutOrchestrator', () => {
describe('cross-tab drag cancel', () => {
it('should drop item into current tab when dropped on tab header after detach', () => {
const { orchestrator, tab1Manager, tab2Manager, gridItem, tabsManager, tab1 } = setupWithTwoTabs();
// Simulate state after cross-tab drag started:
// - Item was detached from source
// - We're on Tab 2 now
// - User releases mouse over tab header (no valid drop target under mouse)
// Expected: Item drops into Tab 2's layout
orchestrator.setState({
draggingGridItem: gridItem.getRef(),
sourceTabKey: tab1.state.key,
});
const tab2 = tabsManager.state.tabs[1];
// @ts-expect-error - accessing private property for testing
orchestrator._sourceDropTarget = tab1Manager;
// @ts-expect-error - accessing private property for testing
// lastDropTarget is the TabItem (set when tab switches)
orchestrator._lastDropTarget = tab2;
// @ts-expect-error - accessing private property for testing
orchestrator._itemDetachedFromSource = true;
// Simulate the item being removed from source (as happens during tab switch)
tab1Manager.draggedGridItemOutside(gridItem);
// Switch to tab 2 (simulating what happens after 600ms hover)
tabsManager.switchToTab(tab2);
// Verify item was removed from tab1
expect(tab1Manager.state.layout.state.children).toHaveLength(0);
// Verify tab2 is empty before drop
expect(tab2Manager.state.layout.state.children).toHaveLength(0);
// Mock _getDropTargetUnderMouse to return null (simulating cursor over tab header)
// @ts-expect-error - accessing private method for testing
const originalGetDropTargetUnderMouse = orchestrator._getDropTargetUnderMouse;
// @ts-expect-error - accessing private method for testing
orchestrator._getDropTargetUnderMouse = jest.fn().mockReturnValue(null);
// Create a mock pointer event
const mockEvent = {
clientX: 100,
clientY: 100,
} as PointerEvent;
// Call _stopDraggingSync (this is what happens on mouse release)
// @ts-expect-error - accessing private method for testing
orchestrator._stopDraggingSync(mockEvent);
// Restore original methods
// @ts-expect-error - accessing private method for testing
orchestrator._getDropTargetUnderMouse = originalGetDropTargetUnderMouse;
// Wait for setTimeout to execute
return new Promise<void>((resolve) => {
setTimeout(() => {
// Verify item was dropped into tab2
expect(tab2Manager.state.layout.state.children).toHaveLength(1);
expect(tab2Manager.state.layout.state.children[0]).toBe(gridItem);
// Tab1 should still be empty
expect(tab1Manager.state.layout.state.children).toHaveLength(0);
// We should still be on tab2
expect(tabsManager.getCurrentTab()).toBe(tab2);
resolve();
}, 0);
});
});
it('should complete normal drop when valid drop target exists', () => {
const { orchestrator, tab1Manager, tab2Manager, gridItem, tab1 } = setupWithTwoTabs();
// Simulate state after cross-tab drag started
orchestrator.setState({
draggingGridItem: gridItem.getRef(),
sourceTabKey: tab1.state.key,
});
// @ts-expect-error - accessing private property for testing
orchestrator._sourceDropTarget = tab1Manager;
// @ts-expect-error - accessing private property for testing
orchestrator._lastDropTarget = tab2Manager;
// @ts-expect-error - accessing private property for testing
orchestrator._itemDetachedFromSource = true;
// Simulate the item being removed from source
tab1Manager.draggedGridItemOutside(gridItem);
expect(tab1Manager.state.layout.state.children).toHaveLength(0);
// Mock _getDropTargetUnderMouse to return the tab2Manager (valid drop target)
// @ts-expect-error - accessing private method for testing
const originalGetDropTargetUnderMouse = orchestrator._getDropTargetUnderMouse;
// @ts-expect-error - accessing private method for testing
orchestrator._getDropTargetUnderMouse = jest.fn().mockReturnValue(tab2Manager);
const mockEvent = {
clientX: 100,
clientY: 100,
} as PointerEvent;
// @ts-expect-error - accessing private method for testing
orchestrator._stopDraggingSync(mockEvent);
// @ts-expect-error - accessing private method for testing
orchestrator._getDropTargetUnderMouse = originalGetDropTargetUnderMouse;
return new Promise<void>((resolve) => {
setTimeout(() => {
// Verify item was NOT returned to source (it should go to tab2)
expect(tab1Manager.state.layout.state.children).toHaveLength(0);
expect(tab2Manager.state.layout.state.children).toHaveLength(1);
expect(tab2Manager.state.layout.state.children[0]).toBe(gridItem);
resolve();
}, 0);
});
});
});
describe('isDragging', () => {
it('should return false when nothing is being dragged', () => {
const { orchestrator } = setup();
expect(orchestrator.isDragging()).toBe(false);
});
it('should return true when dragging a grid item', () => {
const { orchestrator, gridItem } = setup();
orchestrator.setState({ draggingGridItem: gridItem.getRef() });
expect(orchestrator.isDragging()).toBe(true);
});
it('should return true when dragging a row', () => {
const { orchestrator } = setupWithRows();
// Note: draggingRow is set via startRowDrag which requires more setup
// This test verifies the state check logic
orchestrator.setState({ draggingRow: undefined });
expect(orchestrator.isDragging()).toBe(false);
});
});
describe('isDroppedElsewhere', () => {
it('should return false when not dragging', () => {
const { orchestrator } = setup();
expect(orchestrator.isDroppedElsewhere()).toBe(false);
});
it('should return false when source and target are the same', () => {
const { orchestrator } = setup();
// Use the same object reference for both - the comparison is by reference
const mockDropTarget = { state: { key: 'grid-1' } };
// @ts-expect-error - accessing private property for testing
orchestrator._sourceDropTarget = mockDropTarget;
// @ts-expect-error - accessing private property for testing
orchestrator._lastDropTarget = mockDropTarget;
// When source equals target (same reference), it's not dropped elsewhere
expect(orchestrator.isDroppedElsewhere()).toBe(false);
});
it('should return true when source and target differ', () => {
const { orchestrator } = setup();
// @ts-expect-error - accessing private property for testing
orchestrator._sourceDropTarget = { state: { key: 'grid-1' } };
// @ts-expect-error - accessing private property for testing
orchestrator._lastDropTarget = { state: { key: 'grid-2' } };
expect(orchestrator.isDroppedElsewhere()).toBe(true);
});
it('should return false when lastDropTarget is null', () => {
const { orchestrator } = setup();
// @ts-expect-error - accessing private property for testing
orchestrator._sourceDropTarget = { state: { key: 'grid-1' } };
// @ts-expect-error - accessing private property for testing
orchestrator._lastDropTarget = null;
expect(orchestrator.isDroppedElsewhere()).toBe(false);
});
});
describe('getItemLabel (via state)', () => {
it('should extract panel title from AutoGridItem', () => {
const panel = new VizPanel({
title: 'My Panel Title',
key: 'panel-1',
pluginId: 'table',
});
const gridItem = new AutoGridItem({
key: 'grid-item-1',
body: panel,
});
// The label extraction happens internally, we can verify the panel structure
expect(gridItem.state.body.state.title).toBe('My Panel Title');
});
it('should handle panel with empty title', () => {
const panel = new VizPanel({
title: '',
key: 'panel-1',
pluginId: 'table',
});
const gridItem = new AutoGridItem({
key: 'grid-item-1',
body: panel,
});
// Empty title should be falsy, which the orchestrator handles with fallback to 'Panel'
expect(gridItem.state.body.state.title).toBe('');
expect(gridItem.state.body.state.title || 'Panel').toBe('Panel');
});
});
});
describe('AutoGridLayoutManager as DashboardDropTarget', () => {
describe('draggedGridItemInside', () => {
it('should add item at the end when no position specified', () => {
const { manager } = setupAutoGrid();
const newPanel = new VizPanel({ title: 'New Panel', key: 'panel-new', pluginId: 'table' });
const newItem = new AutoGridItem({ key: 'new-item', body: newPanel });
manager.draggedGridItemInside(newItem);
const children = manager.state.layout.state.children;
expect(children.length).toBe(3);
expect(children[2]).toBe(newItem);
});
it('should insert item at specified position', () => {
const { manager } = setupAutoGrid();
const newPanel = new VizPanel({ title: 'New Panel', key: 'panel-new', pluginId: 'table' });
const newItem = new AutoGridItem({ key: 'new-item', body: newPanel });
manager.draggedGridItemInside(newItem, 1);
const children = manager.state.layout.state.children;
expect(children.length).toBe(3);
expect(children[1]).toBe(newItem);
});
it('should insert at beginning when position is 0', () => {
const { manager } = setupAutoGrid();
const newPanel = new VizPanel({ title: 'New Panel', key: 'panel-new', pluginId: 'table' });
const newItem = new AutoGridItem({ key: 'new-item', body: newPanel });
manager.draggedGridItemInside(newItem, 0);
const children = manager.state.layout.state.children;
expect(children.length).toBe(3);
expect(children[0]).toBe(newItem);
});
it('should clear dropPosition and isDropTarget after insertion', () => {
const { manager } = setupAutoGrid();
manager.setState({ dropPosition: 1, isDropTarget: true });
const newPanel = new VizPanel({ title: 'New Panel', key: 'panel-new', pluginId: 'table' });
const newItem = new AutoGridItem({ key: 'new-item', body: newPanel });
manager.draggedGridItemInside(newItem, 1);
expect(manager.state.dropPosition).toBeNull();
expect(manager.state.isDropTarget).toBe(false);
});
it('should convert DashboardGridItem to AutoGridItem', () => {
const { manager } = setupAutoGrid();
const panel = new VizPanel({ title: 'Dashboard Panel', key: 'panel-dgi', pluginId: 'table' });
const dashboardGridItem = new DashboardGridItem({ key: 'dgi-1', body: panel });
manager.draggedGridItemInside(dashboardGridItem, 1);
const children = manager.state.layout.state.children;
expect(children.length).toBe(3);
// The inserted item should be an AutoGridItem containing the panel
expect(children[1]).toBeInstanceOf(AutoGridItem);
expect(children[1].state.body).toBe(panel);
});
});
describe('draggedGridItemOutside', () => {
it('should remove item from children', () => {
const { manager, gridItem1 } = setupAutoGrid();
manager.draggedGridItemOutside(gridItem1);
const children = manager.state.layout.state.children;
expect(children.length).toBe(1);
expect(children.includes(gridItem1)).toBe(false);
});
it('should clear isDropTarget state', () => {
const { manager, gridItem1 } = setupAutoGrid();
manager.setState({ isDropTarget: true });
manager.draggedGridItemOutside(gridItem1);
expect(manager.state.isDropTarget).toBe(false);
});
});
describe('setDropPosition', () => {
it('should set dropPosition state', () => {
const { manager } = setupAutoGrid();
manager.setDropPosition(2);
expect(manager.state.dropPosition).toBe(2);
});
it('should clear dropPosition when set to null', () => {
const { manager } = setupAutoGrid();
manager.setState({ dropPosition: 2 });
manager.setDropPosition(null);
expect(manager.state.dropPosition).toBeNull();
});
});
describe('setIsDropTarget', () => {
it('should set isDropTarget state', () => {
const { manager } = setupAutoGrid();
manager.setIsDropTarget(true);
expect(manager.state.isDropTarget).toBe(true);
});
});
});
function setup() {
const panel = new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'table',
});
const gridItem = new AutoGridItem({
key: 'grid-item-1',
body: panel,
});
const manager = new AutoGridLayoutManager({
layout: new AutoGridLayout({ children: [gridItem] }),
});
const orchestrator = new DashboardLayoutOrchestrator();
new DashboardScene({
body: manager,
layoutOrchestrator: orchestrator,
});
return { orchestrator, manager, gridItem, panel };
}
function setupWithRows() {
const panel = new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'table',
});
const gridItem = new AutoGridItem({
key: 'grid-item-1',
body: panel,
});
const manager = new AutoGridLayoutManager({
layout: new AutoGridLayout({ children: [gridItem] }),
});
const tabsManager = new TabsLayoutManager({
tabs: [new TabItem({ title: 'Tab 1', layout: manager })],
});
const orchestrator = new DashboardLayoutOrchestrator();
new DashboardScene({
body: tabsManager,
layoutOrchestrator: orchestrator,
});
return { orchestrator, manager, gridItem, panel, tabsManager };
}
function setupAutoGrid() {
const panel1 = new VizPanel({
title: 'Panel A',
key: 'panel-1',
pluginId: 'table',
});
const panel2 = new VizPanel({
title: 'Panel B',
key: 'panel-2',
pluginId: 'table',
});
const gridItem1 = new AutoGridItem({
key: 'grid-item-1',
body: panel1,
});
const gridItem2 = new AutoGridItem({
key: 'grid-item-2',
body: panel2,
});
const manager = new AutoGridLayoutManager({
layout: new AutoGridLayout({ children: [gridItem1, gridItem2] }),
});
new DashboardScene({ body: manager });
return { manager, gridItem1, gridItem2, panel1, panel2 };
}
function setupWithTwoTabs() {
// Create panel for Tab 1
const panel1 = new VizPanel({
title: 'Panel in Tab 1',
key: 'panel-tab1',
pluginId: 'table',
});
const gridItem = new AutoGridItem({
key: 'grid-item-tab1',
body: panel1,
});
const tab1Manager = new AutoGridLayoutManager({
key: 'tab1-manager',
layout: new AutoGridLayout({ children: [gridItem] }),
});
const tab1 = new TabItem({
key: 'tab-1',
title: 'Tab 1',
layout: tab1Manager,
});
// Create empty Tab 2
const tab2Manager = new AutoGridLayoutManager({
key: 'tab2-manager',
layout: new AutoGridLayout({ children: [] }),
});
const tab2 = new TabItem({
key: 'tab-2',
title: 'Tab 2',
layout: tab2Manager,
});
const tabsManager = new TabsLayoutManager({
tabs: [tab1, tab2],
});
const orchestrator = new DashboardLayoutOrchestrator();
const dashboard = new DashboardScene({
body: tabsManager,
layoutOrchestrator: orchestrator,
});
// Activate the scene hierarchy to set up parent relationships
dashboard.activate();
return {
orchestrator,
tabsManager,
tab1,
tab2,
tab1Manager,
tab2Manager,
gridItem,
panel1,
dashboard,
};
}
@@ -1,28 +1,88 @@
import { css } from '@emotion/css';
import { PointerEvent as ReactPointerEvent } from 'react';
import { createPortal } from 'react-dom';
import { GrafanaTheme2 } from '@grafana/data';
import { logWarning } from '@grafana/runtime';
import {
sceneGraph,
SceneComponentProps,
SceneObjectBase,
SceneObjectRef,
SceneObjectState,
VizPanel,
SceneGridItemLike,
} from '@grafana/scenes';
import { createPointerDistance } from '@grafana/ui';
import { useStyles2 } from '@grafana/ui';
import { DashboardScene } from './DashboardScene';
import { DashboardDropTarget, isDashboardDropTarget } from './types/DashboardDropTarget';
import { AutoGridLayoutManager } from './layout-auto-grid/AutoGridLayoutManager';
import { RowItem } from './layout-rows/RowItem';
import { RowsLayoutManager } from './layout-rows/RowsLayoutManager';
import { TabItem } from './layout-tabs/TabItem';
import { TabsLayoutManager } from './layout-tabs/TabsLayoutManager';
import {
AUTO_GRID_ITEM_DROP_TARGET_ATTR,
DASHBOARD_DROP_TARGET_KEY_ATTR,
DashboardDropTarget,
isDashboardDropTarget,
} from './types/DashboardDropTarget';
const TAB_ACTIVATION_DELAY_MS = 600;
interface DashboardLayoutOrchestratorState extends SceneObjectState {
/** Grid item currently being dragged */
draggingGridItem?: SceneObjectRef<SceneGridItemLike>;
/** Row currently being dragged */
draggingRow?: SceneObjectRef<RowItem>;
/** Key of the source tab where drag started */
sourceTabKey?: string;
/** Key of the tab currently being hovered during drag */
hoverTabKey?: string;
/** Preview state for cross-tab drag */
dragPreview?: {
x: number;
y: number;
width: number;
height: number;
/** Offset from cursor to top-left of preview (preserves click position) */
offsetX: number;
offsetY: number;
label: string;
type: 'panel' | 'row';
};
}
export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayoutOrchestratorState> {
public static Component = DragPreviewRenderer;
private _sourceDropTarget: DashboardDropTarget | null = null;
private _lastDropTarget: DashboardDropTarget | null = null;
private _pointerDistance = createPointerDistance();
private _isSelectedObject = false;
private _tabActivationTimer: ReturnType<typeof setTimeout> | null = null;
private _lastHoveredTabKey: string | null = null;
/** Track if item was detached from source during cross-tab drag */
private _itemDetachedFromSource = false;
/** Cached label for the preview */
private _previewLabel = '';
/** Cached type for the preview */
private _previewType: 'panel' | 'row' = 'panel';
/** Cached dimensions for the preview */
private _previewWidth = 0;
private _previewHeight = 0;
/** Last known cursor position */
private _lastCursorX = 0;
private _lastCursorY = 0;
/** Offset from cursor to item's top-left corner (captured on drag start) */
private _dragOffsetX = 0;
private _dragOffsetY = 0;
/** Source layout manager for row drag (for removal before tab switch) */
private _sourceRowsLayout: RowsLayoutManager | null = null;
/** Flag to track if row drag offset has been captured */
private _rowOffsetCaptured = false;
/** Current drop position for AutoGrid (index where item will be inserted) */
private _currentDropPosition: number | null = null;
/** Last hovered AutoGrid item key (to prevent flickering) */
private _lastHoveredAutoGridItemKey: string | null = null;
public constructor() {
super({});
@@ -36,14 +96,30 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
private _activationHandler() {
return () => {
document.body.removeEventListener('pointermove', this._onPointerMove);
document.body.removeEventListener('pointerup', this._stopDraggingSync);
document.body.removeEventListener('pointermove', this._onRowDragPointerMove);
document.body.removeEventListener('pointerup', this._stopDraggingSync, true);
document.body.removeEventListener('pointerup', this._onRowDragPointerUp, true);
this._clearTabActivationTimer();
this._clearDragPreview();
};
}
public startDraggingSync(evt: ReactPointerEvent, gridItem: SceneGridItemLike): void {
this._pointerDistance.set(evt);
this._isSelectedObject = false;
/**
* Returns true if any drag operation is in progress (grid item or row)
*/
public isDragging(): boolean {
return !!(this.state.draggingGridItem || this.state.draggingRow);
}
/**
* Returns true if the current drag operation will drop the item to a different layout
* than where it started. Used by AutoGridLayout to know whether to clear draggingKey.
*/
public isDroppedElsewhere(): boolean {
return this._lastDropTarget !== null && this._lastDropTarget !== this._sourceDropTarget;
}
public startDraggingSync(evt: ReactPointerEvent, gridItem: SceneGridItemLike): void {
const dropTarget = sceneGraph.findObject(gridItem, isDashboardDropTarget);
if (!dropTarget || !isDashboardDropTarget(dropTarget)) {
@@ -53,54 +129,469 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
this._sourceDropTarget = dropTarget;
this._lastDropTarget = dropTarget;
document.body.addEventListener('pointermove', this._onPointerMove);
document.body.addEventListener('pointerup', this._stopDraggingSync);
// Capture the offset from cursor to item's top-left corner
this._captureDragOffset(evt.clientX, evt.clientY, gridItem);
this.setState({ draggingGridItem: gridItem.getRef() });
document.body.addEventListener('pointermove', this._onPointerMove);
// Use capture phase to ensure we receive the event even if something calls stopPropagation
// (e.g., tab headers call stopPropagation on pointerup)
document.body.addEventListener('pointerup', this._stopDraggingSync, true);
const sourceTabKey = this._findParentTabKey(gridItem);
this.setState({ draggingGridItem: gridItem.getRef(), sourceTabKey });
}
private _stopDraggingSync(_evt: PointerEvent) {
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;
const dropPosition = this._currentDropPosition;
if (this._sourceDropTarget !== this._lastDropTarget) {
// Wrapped in setTimeout to ensure that any event handlers are called
// Useful for allowing react-grid-layout to remove placeholders, etc.
// Check if there's a valid drop target under the mouse
// (tab headers and other non-drop areas return null)
const validDropTargetUnderMouse = this._getDropTargetUnderMouse(evt);
// If item was detached (cross-tab drag started) but there's no valid drop target under mouse,
// drop into the current tab if lastDropTarget is a TabItem (e.g., dropped on tab header)
const noTargetUnderMouse = wasDetached && !validDropTargetUnderMouse && gridItem;
const canDropIntoCurrentTab = noTargetUnderMouse && lastDropTarget instanceof TabItem;
if (canDropIntoCurrentTab) {
// Drop into the current tab's layout
setTimeout(() => {
if (gridItem) {
// Always use grid item dragging
this._sourceDropTarget?.draggedGridItemOutside?.(gridItem);
this._lastDropTarget?.draggedGridItemInside?.(gridItem);
} else {
const warningMessage = 'No grid item to drag';
console.warn(warningMessage);
logWarning(warningMessage);
lastDropTarget.draggedGridItemInside?.(gridItem);
// Clean up source grid state
if (sourceDropTarget instanceof AutoGridLayoutManager) {
sourceDropTarget.state.layout.endExternalDrag();
}
});
} else {
const isCrossLayoutDrop = sourceDropTarget !== lastDropTarget || wasDetached;
// Handle cross-layout or cross-tab drop
if (isCrossLayoutDrop) {
// 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) {
sourceDropTarget?.draggedGridItemOutside?.(gridItem);
}
// Pass drop position for precise placement (AutoGrid uses this)
// Note: draggedGridItemInside also clears isDropTarget and dropPosition
lastDropTarget?.draggedGridItemInside?.(gridItem, dropPosition ?? undefined);
// Clean up source grid's drag state (CSS variables and draggingKey) after item is moved.
// This is done here (after movement) to prevent flickering where the item
// would momentarily appear at wrong position (CSS vars cleared but draggingKey set
// = absolute positioning with no valid position values).
if (sourceDropTarget instanceof AutoGridLayoutManager) {
sourceDropTarget.state.layout.endExternalDrag();
}
} else {
const warningMessage = 'No grid item to drag';
console.warn(warningMessage);
logWarning(warningMessage);
}
});
} else {
// For same-layout drops, clear drop position state synchronously
this._clearDropPosition();
this._lastDropTarget?.setIsDropTarget?.(false);
}
}
document.body.removeEventListener('pointermove', this._onPointerMove);
document.body.removeEventListener('pointerup', this._stopDraggingSync);
document.body.removeEventListener('pointerup', this._stopDraggingSync, true);
this.setState({ draggingGridItem: undefined });
this._clearTabActivationTimer();
this._clearDragPreview();
// Clear internal tracking state (but not the visual state on the target for cross-layout drops)
this._currentDropPosition = null;
this._lastHoveredAutoGridItemKey = null;
this._lastDropTarget = null;
this._sourceDropTarget = null;
this._itemDetachedFromSource = false;
this.setState({ draggingGridItem: undefined, sourceTabKey: undefined, hoverTabKey: undefined });
}
/**
* Called when a row drag starts (from RowsLayoutManagerRenderer)
*/
public startRowDrag(row: RowItem): void {
const sourceTabKey = this._findParentTabKey(row);
// Store source layout info for removal before tab switch
const parent = row.parent;
if (parent instanceof RowsLayoutManager) {
this._sourceRowsLayout = parent;
}
// Capture row dimensions
this._captureRowDimensions(row);
// Offset will be captured on first pointermove
this._rowOffsetCaptured = false;
this.setState({
draggingRow: row.getRef(),
sourceTabKey,
});
// Add pointer move listener for tab hover detection during row drag
document.body.addEventListener('pointermove', this._onRowDragPointerMove);
// Add pointerup listener to handle drop after cross-tab switch
// Use capture phase to ensure we receive the event even if something calls stopPropagation
document.body.addEventListener('pointerup', this._onRowDragPointerUp, true);
}
private _onRowDragPointerMove = (evt: PointerEvent): void => {
// Capture row offset on first move (we don't have cursor position at drag start)
if (!this._rowOffsetCaptured) {
const row = this.state.draggingRow?.resolve();
if (row) {
this._captureRowDragOffset(evt.clientX, evt.clientY, row);
this._rowOffsetCaptured = true;
}
}
// Store cursor position early so it's available for immediate preview on tab switch
this._lastCursorX = evt.clientX;
this._lastCursorY = evt.clientY;
this._checkTabHover(evt.clientX, evt.clientY);
this._updateDragPreview(evt.clientX, evt.clientY);
};
private _onRowDragPointerUp = (_evt: PointerEvent): void => {
// Always clear the tab activation timer on pointerup to prevent
// the tab from switching after the user has released the mouse
this._clearTabActivationTimer();
// Handle drop after cross-tab row drag
if (this._itemDetachedFromSource) {
const row = this.state.draggingRow?.resolve();
if (row) {
// Find the drop target under cursor and add row to it
const dropTarget = this._lastDropTarget ?? this._getDropTargetUnderMouse(_evt);
if (dropTarget instanceof TabItem) {
dropTarget.acceptDroppedRow?.(row);
}
}
this._finalizeRowDrag();
}
// If not detached, stopRowDrag from hello-pangea/dnd will handle cleanup
};
/**
* Called when a row drag ends (from RowsLayoutManagerRenderer)
* This is called by hello-pangea/dnd when drag ends normally (within same layout)
* For cross-tab drags, the row is already detached and _onRowDragPointerUp handles the drop
*/
public stopRowDrag(): void {
// If the row was detached (cross-tab drag), don't clean up yet
// The pointerup handler will handle cleanup after drop
if (this._itemDetachedFromSource) {
return;
}
this._finalizeRowDrag();
}
private _finalizeRowDrag(): void {
document.body.removeEventListener('pointermove', this._onRowDragPointerMove);
document.body.removeEventListener('pointerup', this._onRowDragPointerUp, true);
this._clearTabActivationTimer();
this._clearDragPreview();
this._lastDropTarget?.setIsDropTarget?.(false);
this._lastDropTarget = null;
this._sourceDropTarget = null;
this._itemDetachedFromSource = false;
this._sourceRowsLayout = null;
this._rowOffsetCaptured = false;
this.setState({
draggingRow: undefined,
sourceTabKey: undefined,
hoverTabKey: undefined,
});
}
private _clearTabActivationTimer(): void {
if (this._tabActivationTimer) {
clearTimeout(this._tabActivationTimer);
this._tabActivationTimer = null;
}
}
private _updateDragPreview(x: number, y: number): void {
// Store cursor position for immediate preview on tab switch
this._lastCursorX = x;
this._lastCursorY = y;
if (this._itemDetachedFromSource) {
this.setState({
dragPreview: {
x,
y,
width: this._previewWidth,
height: this._previewHeight,
offsetX: this._dragOffsetX,
offsetY: this._dragOffsetY,
label: this._previewLabel,
type: this._previewType,
},
});
}
}
private _showDragPreview(): void {
// Prevent text selection and set move cursor during cross-tab drag
document.body.classList.add('dashboard-draggable-transparent-selection');
document.body.classList.add('dragging-active');
this.setState({
dragPreview: {
x: this._lastCursorX,
y: this._lastCursorY,
width: this._previewWidth,
height: this._previewHeight,
offsetX: this._dragOffsetX,
offsetY: this._dragOffsetY,
label: this._previewLabel,
type: this._previewType,
},
});
}
private _captureDragOffset(cursorX: number, cursorY: number, gridItem: SceneGridItemLike): void {
// Both DashboardGridItem and AutoGridItem have containerRef
if ('containerRef' in gridItem) {
const containerRef = gridItem.containerRef;
if (
containerRef &&
typeof containerRef === 'object' &&
'current' in containerRef &&
containerRef.current instanceof HTMLElement
) {
const rect = containerRef.current.getBoundingClientRect();
// Offset is cursor position minus item's top-left
this._dragOffsetX = cursorX - rect.left;
this._dragOffsetY = cursorY - rect.top;
return;
}
}
// Fallback: center the preview on cursor
this._dragOffsetX = 0;
this._dragOffsetY = 0;
}
private _captureItemDimensions(gridItem: SceneGridItemLike): void {
// Both DashboardGridItem and AutoGridItem have containerRef
if ('containerRef' in gridItem) {
const containerRef = gridItem.containerRef;
if (
containerRef &&
typeof containerRef === 'object' &&
'current' in containerRef &&
containerRef.current instanceof HTMLElement
) {
const rect = containerRef.current.getBoundingClientRect();
this._previewWidth = rect.width;
this._previewHeight = rect.height;
return;
}
}
// Fallback to reasonable default
this._previewWidth = 400;
this._previewHeight = 300;
}
private _captureRowDimensions(row: RowItem): void {
// Try to find the DOM element for the row using DASHBOARD_DROP_TARGET_KEY_ATTR
const element = document.querySelector(`[${DASHBOARD_DROP_TARGET_KEY_ATTR}="${row.state.key}"]`);
if (element) {
const rect = element.getBoundingClientRect();
this._previewWidth = rect.width;
this._previewHeight = rect.height;
return;
}
// Fallback to reasonable default for rows
this._previewWidth = 800;
this._previewHeight = 48;
}
private _captureRowDragOffset(cursorX: number, cursorY: number, row: RowItem): void {
// Try to find the DOM element for the row
const element = document.querySelector(`[${DASHBOARD_DROP_TARGET_KEY_ATTR}="${row.state.key}"]`);
if (element) {
const rect = element.getBoundingClientRect();
this._dragOffsetX = cursorX - rect.left;
this._dragOffsetY = cursorY - rect.top;
return;
}
// Fallback: use small offset
this._dragOffsetX = 20;
this._dragOffsetY = 20;
}
private _clearDragPreview(): void {
// Re-enable text selection and reset cursor
document.body.classList.remove('dashboard-draggable-transparent-selection');
document.body.classList.remove('dragging-active');
window.getSelection()?.removeAllRanges();
if (this.state.dragPreview) {
this.setState({ dragPreview: undefined });
}
}
private _checkTabHover(clientX: number, clientY: number): void {
const tabKey = this._getTabUnderMouse(clientX, clientY);
if (tabKey !== this._lastHoveredTabKey) {
// Cursor moved to a different tab or left all tabs
this._clearTabActivationTimer();
this._lastHoveredTabKey = tabKey;
if (tabKey) {
// Check if this tab is already active - no need to switch
if (this._isTabAlreadyActive(tabKey)) {
this.setState({ hoverTabKey: undefined });
return;
}
// Start new timer for the new tab
this._tabActivationTimer = setTimeout(() => {
this._activateTab(tabKey);
}, TAB_ACTIVATION_DELAY_MS);
this.setState({ hoverTabKey: tabKey });
} else {
this.setState({ hoverTabKey: undefined });
}
}
}
private _isTabAlreadyActive(tabKey: string): boolean {
const dashboard = this._getDashboard();
const tabItem = sceneGraph.findByKey(dashboard, tabKey);
if (tabItem instanceof TabItem) {
const tabsManager = tabItem.getParentLayout();
if (tabsManager instanceof TabsLayoutManager) {
const currentTab = tabsManager.getCurrentTab();
return currentTab === tabItem;
}
}
return false;
}
private _activateTab(tabKey: string): void {
const dashboard = this._getDashboard();
const tabItem = sceneGraph.findByKey(dashboard, tabKey);
if (tabItem instanceof TabItem) {
const tabsManager = tabItem.getParentLayout();
if (tabsManager instanceof TabsLayoutManager) {
// For grid items: remove from source BEFORE switching tabs
// This prevents the item from being unmounted with the source tab
const gridItem = this.state.draggingGridItem?.resolve();
if (gridItem && this._sourceDropTarget && !this._itemDetachedFromSource) {
// Get label and dimensions for preview before detaching
this._previewLabel = this._getItemLabel(gridItem);
this._previewType = 'panel';
this._captureItemDimensions(gridItem);
this._sourceDropTarget.draggedGridItemOutside?.(gridItem);
this._itemDetachedFromSource = true;
// Show preview immediately using last known cursor position
this._showDragPreview();
}
// For rows: remove from source layout and show preview
const row = this.state.draggingRow?.resolve();
if (row && !this._itemDetachedFromSource && this._sourceRowsLayout) {
// Get label for preview (dimensions already captured in startRowDrag)
this._previewLabel = row.state.title || 'Row';
this._previewType = 'row';
// Remove row from source layout (skip undo as this is part of drag operation)
this._sourceRowsLayout.removeRow(row, true);
this._itemDetachedFromSource = true;
// Show preview immediately
this._showDragPreview();
}
tabsManager.switchToTab(tabItem);
// Update last drop target to the new tab
// This ensures drop works even if user releases immediately after tab switch
if (isDashboardDropTarget(tabItem)) {
this._lastDropTarget = tabItem;
}
}
}
}
private _getItemLabel(gridItem: SceneGridItemLike): string {
if ('state' in gridItem && 'body' in gridItem.state && gridItem.state.body instanceof VizPanel) {
return gridItem.state.body.state.title || 'Panel';
}
return 'Panel';
}
private _getTabUnderMouse(clientX: number, clientY: number): string | null {
const elementsUnderPoint = document.elementsFromPoint(clientX, clientY);
const tabKey = elementsUnderPoint
?.find((element) => element.getAttribute('data-tab-activation-key'))
?.getAttribute('data-tab-activation-key');
return tabKey || null;
}
private _findParentTabKey(item: RowItem | SceneGridItemLike): string | undefined {
let parent = item.parent;
while (parent) {
if (parent instanceof TabItem) {
return parent.state.key;
}
parent = parent.parent;
}
return undefined;
}
private _onPointerMove(evt: PointerEvent) {
if (!this._isSelectedObject && this.state.draggingGridItem && this._pointerDistance.check(evt)) {
this._isSelectedObject = true;
const gridItem = this.state.draggingGridItem?.resolve();
if (gridItem && 'state' in gridItem && 'body' in gridItem.state && gridItem.state.body instanceof VizPanel) {
const panel = gridItem.state.body;
this._getDashboard().state.editPane.selectObject(panel, panel.state.key!, { force: true, multi: false });
}
}
// Store cursor position early so it's available for immediate preview on tab switch
this._lastCursorX = evt.clientX;
this._lastCursorY = evt.clientY;
// Check for tab hover to enable tab switching during drag
this._checkTabHover(evt.clientX, evt.clientY);
// Update drag preview position if item is detached
this._updateDragPreview(evt.clientX, evt.clientY);
const dropTarget = this._getDropTargetUnderMouse(evt) ?? this._sourceDropTarget;
if (!dropTarget) {
this._clearDropPosition();
return;
}
if (dropTarget !== this._lastDropTarget) {
// Clear drop position from previous target
this._clearDropPosition();
this._lastDropTarget?.setIsDropTarget?.(false);
this._lastDropTarget = dropTarget;
@@ -108,6 +599,76 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
dropTarget.setIsDropTarget?.(true);
}
}
// Update drop position for AutoGrid targets
this._updateDropPosition(evt.clientX, evt.clientY, dropTarget);
}
private _updateDropPosition(clientX: number, clientY: number, dropTarget: DashboardDropTarget): void {
// Only update position for AutoGridLayoutManager targets
if (!(dropTarget instanceof AutoGridLayoutManager)) {
return;
}
// Don't show external placeholder when dragging within the same grid
// (AutoGrid has its own internal drag placeholder)
if (dropTarget === this._sourceDropTarget) {
return;
}
// Find which AutoGridItem we're hovering over
const elementsUnderPoint = document.elementsFromPoint(clientX, clientY);
const targetElement = elementsUnderPoint?.find((el) => el.getAttribute(AUTO_GRID_ITEM_DROP_TARGET_ATTR));
const targetKey = targetElement?.getAttribute(AUTO_GRID_ITEM_DROP_TARGET_ATTR);
const children = dropTarget.state.layout.state.children;
// If not hovering over any item
if (!targetKey || !targetElement) {
// Only set initial position when first entering the grid
if (this._currentDropPosition === null) {
this._currentDropPosition = children.length;
dropTarget.setDropPosition?.(children.length);
}
// Otherwise keep the current position (prevents flickering when over placeholder)
return;
}
// Determine if we should insert before or after the hovered item
// by checking if cursor is in left half or right half
const rect = targetElement.getBoundingClientRect();
const isRightHalf = clientX > rect.left + rect.width / 2;
// Create a composite key that includes both item key and side
const compositeKey = `${targetKey}-${isRightHalf ? 'after' : 'before'}`;
// Only update if we're hovering over a different position than before
// This prevents flickering when the placeholder shifts items around
if (compositeKey === this._lastHoveredAutoGridItemKey) {
return;
}
this._lastHoveredAutoGridItemKey = compositeKey;
// Find the index of the hovered item
const hoveredIndex = children.findIndex((child) => child.state.key === targetKey);
if (hoveredIndex < 0) {
return;
}
// Insert after if in right half, before if in left half
const newPosition = isRightHalf ? hoveredIndex + 1 : hoveredIndex;
this._currentDropPosition = newPosition;
dropTarget.setDropPosition?.(newPosition);
}
private _clearDropPosition(): void {
if (this._currentDropPosition !== null && this._lastDropTarget) {
this._lastDropTarget.setDropPosition?.(null);
this._currentDropPosition = null;
}
this._lastHoveredAutoGridItemKey = null;
}
private _getDashboard(): DashboardScene {
@@ -121,7 +682,7 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
private _getDropTargetUnderMouse(evt: MouseEvent): DashboardDropTarget | null {
const elementsUnderPoint = document.elementsFromPoint(evt.clientX, evt.clientY);
const cursorIsInSourceTarget = elementsUnderPoint.some(
(el) => el.getAttribute('data-dashboard-drop-target-key') === this._sourceDropTarget?.state.key
(el) => el.getAttribute(DASHBOARD_DROP_TARGET_KEY_ATTR) === this._sourceDropTarget?.state.key
);
if (cursorIsInSourceTarget) {
@@ -129,8 +690,8 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
}
const key = elementsUnderPoint
?.find((element) => element.getAttribute('data-dashboard-drop-target-key'))
?.getAttribute('data-dashboard-drop-target-key');
?.find((element) => element.getAttribute(DASHBOARD_DROP_TARGET_KEY_ATTR))
?.getAttribute(DASHBOARD_DROP_TARGET_KEY_ATTR);
if (!key) {
return null;
@@ -145,3 +706,61 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
return sceneObject;
}
}
/**
* Renders a floating drag preview when an item is detached during cross-tab drag
*/
function DragPreviewRenderer({ model }: SceneComponentProps<DashboardLayoutOrchestrator>) {
const { dragPreview } = model.useState();
const styles = useStyles2(getPreviewStyles);
if (!dragPreview) {
return null;
}
// Position preview so cursor maintains same relative position as when drag started
const previewLeft = dragPreview.x - dragPreview.offsetX;
const previewTop = dragPreview.y - dragPreview.offsetY;
const preview = (
<div
className={styles.preview}
style={{
left: previewLeft,
top: previewTop,
width: dragPreview.width,
height: dragPreview.height,
}}
>
<span className={styles.label}>{dragPreview.label}</span>
</div>
);
return createPortal(preview, document.body);
}
const getPreviewStyles = (theme: GrafanaTheme2) => ({
preview: css({
position: 'fixed',
background: theme.colors.background.primary,
border: `1px dashed ${theme.colors.primary.main}`,
borderRadius: theme.shape.radius.default,
boxShadow: theme.shadows.z3,
pointerEvents: 'none',
zIndex: theme.zIndex.tooltip,
overflow: 'hidden',
opacity: 0.9,
}),
label: css({
// Match panel header styling
display: 'flex',
alignItems: 'center',
height: theme.spacing(theme.components.panel.headerHeight),
padding: theme.spacing(0.5, 1, 0, 1.5),
color: theme.colors.text.primary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
...theme.typography.h6,
}),
});
@@ -2,17 +2,19 @@ import { css, cx } from '@emotion/css';
import { memo, useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { LazyLoader, SceneComponentProps, VizPanel } from '@grafana/scenes';
import { LazyLoader, sceneGraph, SceneComponentProps, VizPanel } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup';
import { useIsConditionallyHidden } from '../../conditional-rendering/hooks/useIsConditionallyHidden';
import { useDashboardState } from '../../utils/utils';
import { SoloPanelContextValueWithSearchStringFilter } from '../PanelSearchLayout';
import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext';
import { useSoloPanelContext, renderMatchingSoloPanels } from '../SoloPanelContext';
import { getIsLazy } from '../layouts-shared/utils';
import { AUTO_GRID_ITEM_DROP_TARGET_ATTR } from '../types/DashboardDropTarget';
import { AutoGridItem } from './AutoGridItem';
import { AutoGridLayoutManager } from './AutoGridLayoutManager';
import { DRAGGED_ITEM_HEIGHT, DRAGGED_ITEM_LEFT, DRAGGED_ITEM_TOP, DRAGGED_ITEM_WIDTH } from './const';
export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem>) {
@@ -23,6 +25,10 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
const soloPanelContext = useSoloPanelContext();
const isLazy = useMemo(() => getIsLazy(preload), [preload]);
// Check if this grid is a drop target for external drags
const layoutManager = sceneGraph.getAncestor(model, AutoGridLayoutManager);
const { isDropTarget } = layoutManager.useState();
const Wrapper = useMemo(
() =>
// eslint-disable-next-line react/display-name
@@ -32,14 +38,14 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
conditionalRendering,
addDndContainer,
isDragged,
isDragging,
showDropTarget,
isRepeat = false,
}: {
item: VizPanel;
conditionalRendering?: ConditionalRenderingGroup;
addDndContainer: boolean;
isDragged: boolean;
isDragging: boolean;
showDropTarget: boolean;
isRepeat?: boolean;
}) => {
const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay, renderHidden] =
@@ -48,7 +54,7 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
return isConditionallyHidden && !isEditing && !renderHidden ? null : (
<div
{...(addDndContainer
? { ref: model.containerRef, ['data-auto-grid-item-drop-target']: isDragging ? key : undefined }
? { ref: model.containerRef, [AUTO_GRID_ITEM_DROP_TARGET_ATTR]: showDropTarget ? key : undefined }
: {})}
className={cx(isConditionallyHidden && !isEditing && styles.hidden)}
>
@@ -99,6 +105,8 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
const isDragging = !!draggingKey;
const isDragged = draggingKey === key;
// Show drop target attribute for both internal drags and external drags (when this grid is a drop target)
const showDropTarget = isDragging || !!isDropTarget;
return (
<>
@@ -108,7 +116,7 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
addDndContainer={true}
key={body.state.key!}
isDragged={isDragged}
isDragging={isDragging}
showDropTarget={showDropTarget}
/>
{repeatedPanels.map((item, idx) => (
<Wrapper
@@ -117,7 +125,7 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
addDndContainer={false}
key={item.state.key!}
isDragged={isDragged}
isDragging={isDragging}
showDropTarget={showDropTarget}
isRepeat={true}
/>
))}
@@ -4,6 +4,7 @@ import { SceneLayout, SceneObjectBase, SceneObjectState, VizPanel, SceneGridItem
import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { getLayoutOrchestratorFor } from '../../utils/utils';
import { AUTO_GRID_ITEM_DROP_TARGET_ATTR } from '../types/DashboardDropTarget';
import { AutoGridItem } from './AutoGridItem';
import { AutoGridLayoutRenderer } from './AutoGridLayoutRenderer';
@@ -65,6 +66,8 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
top: number;
left: number;
} | null = null;
/** Container's initial page position, used to compensate for layout shifts during drag */
private _initialContainerRect: { top: number; left: number } | null = null;
private _lastDropTargetGridItemKey: string | null = null;
public constructor(state: Partial<AutoGridLayoutState>) {
@@ -150,6 +153,12 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
const { top, left, width, height } = this._draggedGridItem.getBoundingBox();
this._initialGridItemPosition = { pageX: evt.pageX, pageY: evt.pageY, top, left: left };
// Capture container's initial page position to compensate for layout shifts
// (e.g., when a grid above expands due to placeholder insertion)
const containerRect = this.containerRef.current?.getBoundingClientRect();
this._initialContainerRect = containerRect ? { top: containerRect.top, left: containerRect.left } : null;
this._updatePanelSize(width, height);
this._updatePanelPosition(top, left);
@@ -168,16 +177,33 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
this._draggedGridItem = null;
this._initialGridItemPosition = null;
this._initialContainerRect = null;
this._lastDropTargetGridItemKey = null;
this._resetPanelPositionAndSize();
this.setState({ draggingKey: undefined });
// Only reset position/size and clear draggingKey if not dropping to a different layout.
// For cross-grid drops, the orchestrator will call endExternalDrag() after the item is moved
// to prevent flickering where the item would momentarily appear at wrong position
// (CSS vars cleared but draggingKey still set = absolute positioning with no position).
const orchestrator = getLayoutOrchestratorFor(this);
if (!orchestrator?.isDroppedElsewhere()) {
this._resetPanelPositionAndSize();
this.setState({ draggingKey: undefined });
}
document.body.removeEventListener('pointermove', this._onDrag);
document.body.removeEventListener('pointerup', this._onDragEnd);
document.body.classList.remove('dashboard-draggable-transparent-selection');
}
/**
* Called by the orchestrator after a cross-layout drag ends and the item has been moved.
* Cleans up the drag state that was preserved during the cross-layout drop.
*/
public endExternalDrag(): void {
this._resetPanelPositionAndSize();
this.setState({ draggingKey: undefined });
}
// Handle inside drag moves
private _onDrag(evt: PointerEvent) {
if (!this._draggedGridItem || !this._initialGridItemPosition) {
@@ -185,19 +211,30 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
return;
}
// Calculate how much the container has shifted since drag started
// This can happen when a grid above expands (e.g., placeholder causes row wrap)
let containerShiftY = 0;
let containerShiftX = 0;
if (this._initialContainerRect && this.containerRef.current) {
const currentRect = this.containerRef.current.getBoundingClientRect();
containerShiftY = currentRect.top - this._initialContainerRect.top;
containerShiftX = currentRect.left - this._initialContainerRect.left;
}
// Adjust position to compensate for container movement
this._updatePanelPosition(
this._initialGridItemPosition.top + (evt.pageY - this._initialGridItemPosition.pageY),
this._initialGridItemPosition.left + (evt.pageX - this._initialGridItemPosition.pageX)
this._initialGridItemPosition.top + (evt.pageY - this._initialGridItemPosition.pageY) - containerShiftY,
this._initialGridItemPosition.left + (evt.pageX - this._initialGridItemPosition.pageX) - containerShiftX
);
const dropTargetGridItemKey = document
.elementsFromPoint(evt.clientX, evt.clientY)
?.find((element) => {
const key = element.getAttribute('data-auto-grid-item-drop-target');
const key = element.getAttribute(AUTO_GRID_ITEM_DROP_TARGET_ATTR);
return !!key && key !== this._draggedGridItem!.state.key;
})
?.getAttribute('data-auto-grid-item-drop-target');
?.getAttribute(AUTO_GRID_ITEM_DROP_TARGET_ATTR);
if (dropTargetGridItemKey && dropTargetGridItemKey !== this._lastDropTargetGridItemKey) {
this._onDragOverItem(dropTargetGridItemKey);
@@ -23,6 +23,7 @@ import {
} from '../../utils/utils';
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
import { clearClipboard, getAutoGridItemFromClipboard } from '../layouts-shared/paste';
import { DashboardDropTarget } from '../types/DashboardDropTarget';
import { DashboardLayoutGrid } from '../types/DashboardLayoutGrid';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
@@ -37,6 +38,10 @@ interface AutoGridLayoutManagerState extends SceneObjectState {
rowHeight: AutoGridRowHeight;
columnWidth: AutoGridColumnWidth;
fillScreen: boolean;
/** Whether this grid is currently a drop target */
isDropTarget?: boolean;
/** Position index where a placeholder should be shown for external drops */
dropPosition?: number | null;
}
export type AutoGridColumnWidth = 'narrow' | 'standard' | 'wide' | 'custom' | number;
@@ -46,10 +51,14 @@ export const AUTO_GRID_DEFAULT_MAX_COLUMN_COUNT = 3;
export const AUTO_GRID_DEFAULT_COLUMN_WIDTH = 'standard';
export const AUTO_GRID_DEFAULT_ROW_HEIGHT = 'standard';
export class AutoGridLayoutManager extends SceneObjectBase<AutoGridLayoutManagerState> implements DashboardLayoutGrid {
export class AutoGridLayoutManager
extends SceneObjectBase<AutoGridLayoutManagerState>
implements DashboardLayoutGrid, DashboardDropTarget
{
public static Component = AutoGridLayoutManagerRenderer;
public readonly isDashboardLayoutManager = true;
public readonly isDashboardDropTarget = true as const;
public static readonly descriptor: LayoutRegistryItem = {
get name() {
@@ -359,6 +368,58 @@ export class AutoGridLayoutManager extends SceneObjectBase<AutoGridLayoutManager
this.state.layout.setState({ children: [...this.state.layout.state.children, gridItem] });
}
public setIsDropTarget(isDropTarget: boolean): void {
this.setState({ isDropTarget });
}
public setDropPosition(position: number | null): void {
this.setState({ dropPosition: position });
}
public draggedGridItemOutside(gridItem: SceneGridItemLike): void {
if (gridItem instanceof AutoGridItem) {
this.state.layout.setState({
children: this.state.layout.state.children.filter((child) => child !== gridItem),
});
}
this.setState({ isDropTarget: false });
}
public draggedGridItemInside(gridItem: SceneGridItemLike, position?: number): void {
let newGridItem: AutoGridItem;
if (gridItem instanceof AutoGridItem) {
gridItem.clearParent();
newGridItem = gridItem;
} else if (gridItem instanceof DashboardGridItem) {
if (!(gridItem.state.body instanceof VizPanel)) {
throw new Error('DashboardGridItem body is not a VizPanel');
}
const panel = gridItem.state.body;
panel.clearParent();
newGridItem = new AutoGridItem({
body: panel,
variableName: gridItem.state.variableName,
});
} else {
throw new Error('Grid item must be an AutoGridItem or DashboardGridItem');
}
const children = [...this.state.layout.state.children];
if (position !== undefined && position >= 0 && position <= children.length) {
// Insert at specific position
children.splice(position, 0, newGridItem);
} else {
// Append to end
children.push(newGridItem);
}
this.state.layout.setState({ children });
this.setState({ isDropTarget: false, dropPosition: null });
}
}
function AutoGridLayoutManagerRenderer({ model }: SceneComponentProps<AutoGridLayoutManager>) {
@@ -9,6 +9,7 @@ import { useDashboardState } from '../../utils/utils';
import { useSoloPanelContext } from '../SoloPanelContext';
import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions';
import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles';
import { DASHBOARD_DROP_TARGET_KEY_ATTR } from '../types/DashboardDropTarget';
import { AutoGridLayout, AutoGridLayoutState } from './AutoGridLayout';
import { AutoGridLayoutManager } from './AutoGridLayoutManager';
@@ -18,7 +19,7 @@ export function AutoGridLayoutRenderer({ model }: SceneComponentProps<AutoGridLa
const styles = useStyles2(getStyles, model.state);
const { layoutOrchestrator, isEditing } = useDashboardState(model);
const layoutManager = sceneGraph.getAncestor(model, AutoGridLayoutManager);
const { fillScreen } = layoutManager.useState();
const { fillScreen, dropPosition } = layoutManager.useState();
const soloPanelContext = useSoloPanelContext();
if (isHidden || !layoutOrchestrator) {
@@ -31,19 +32,44 @@ export function AutoGridLayoutRenderer({ model }: SceneComponentProps<AutoGridLa
return children.map((item) => <item.Component key={item.state.key} model={item} />);
}
// Build children with placeholder inserted at dropPosition
const renderChildren = () => {
if (dropPosition === null || dropPosition === undefined) {
return children.map((item) => <item.Component key={item.state.key} model={item} />);
}
const result: React.ReactNode[] = [];
const insertPosition = Math.min(dropPosition, children.length);
for (let i = 0; i <= children.length; i++) {
if (i === insertPosition) {
result.push(<DropPlaceholder key="drop-placeholder" styles={styles} />);
}
if (i < children.length) {
const item = children[i];
result.push(<item.Component key={item.state.key} model={item} />);
}
}
return result;
};
return (
<div
className={cx(styles.container, fillScreen && styles.containerFillScreen, isEditing && styles.containerEditing)}
ref={model.containerRef}
{...{ [DASHBOARD_DROP_TARGET_KEY_ATTR]: layoutManager.state.key }}
>
{children.map((item) => (
<item.Component key={item.state.key} model={item} />
))}
{renderChildren()}
{showCanvasActions && <CanvasGridAddActions layoutManager={layoutManager} />}
</div>
);
}
function DropPlaceholder({ styles }: { styles: ReturnType<typeof getStyles> }) {
return <div className={styles.dropPlaceholder} />;
}
const getStyles = (theme: GrafanaTheme2, state: AutoGridLayoutState) => ({
container: css({
display: 'grid',
@@ -72,4 +98,10 @@ const getStyles = (theme: GrafanaTheme2, state: AutoGridLayoutState) => ({
}),
containerFillScreen: css({ flexGrow: 1 }),
containerEditing: css({ paddingBottom: theme.spacing(5), position: 'relative' }),
dropPlaceholder: css({
border: `1px dashed ${theme.colors.primary.main}`,
borderRadius: theme.shape.radius.default,
backgroundColor: theme.colors.primary.transparent,
minHeight: '100px',
}),
});
@@ -193,8 +193,23 @@ export class RowItem
if (gridItem instanceof DashboardGridItem || gridItem instanceof AutoGridItem) {
const layout = gridItem.parent;
if (gridItem instanceof DashboardGridItem && layout instanceof SceneGridLayout) {
// Toggle isDraggable off to force react-grid-layout to exit drag mode
// This clears react-grid-layout's internal drag state
// This is a workaround until we upgrade to react-grid-layout 2.x.x
const wasDraggable = layout.state.isDraggable;
if (wasDraggable) {
layout.setState({ isDraggable: false });
}
const newChildren = layout.state.children.filter((child) => child !== gridItem);
layout.setState({ children: newChildren });
// Restore isDraggable after a microtask to ensure react-grid-layout processes the change
if (wasDraggable) {
queueMicrotask(() => {
layout.setState({ isDraggable: true });
});
}
} else if (gridItem instanceof AutoGridItem && layout instanceof AutoGridLayout) {
const newChildren = layout.state.children.filter((child) => child !== gridItem);
layout.setState({ children: newChildren });
@@ -13,6 +13,7 @@ import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { useDashboardState, useInterpolatedTitle } from '../../utils/utils';
import { DashboardScene } from '../DashboardScene';
import { useSoloPanelContext } from '../SoloPanelContext';
import { DASHBOARD_DROP_TARGET_KEY_ATTR } from '../types/DashboardDropTarget';
import { isDashboardLayoutGrid } from '../types/DashboardLayoutGrid';
import { RowItem } from './RowItem';
@@ -85,7 +86,7 @@ export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
dragProvided.innerRef(ref);
model.containerRef.current = ref;
}}
data-dashboard-drop-target-key={isDashboardLayoutGrid(layout) ? model.state.key : undefined}
{...{ [DASHBOARD_DROP_TARGET_KEY_ATTR]: isDashboardLayoutGrid(layout) ? model.state.key : undefined }}
className={cx(
styles.wrapper,
!isCollapsed && styles.wrapperNotCollapsed,
@@ -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[] = [];
@@ -1,5 +1,6 @@
import { css } from '@emotion/css';
import { DragDropContext, Droppable } from '@hello-pangea/dnd';
import { DragDropContext, Droppable, BeforeCapture, DropResult } from '@hello-pangea/dnd';
import { useCallback } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -8,9 +9,10 @@ import { MultiValueVariable, SceneComponentProps, sceneGraph, useSceneObjectStat
import { Button, useStyles2 } from '@grafana/ui';
import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { useDashboardState } from '../../utils/utils';
import { useDashboardState, getLayoutOrchestratorFor } from '../../utils/utils';
import { useSoloPanelContext } from '../SoloPanelContext';
import { useClipboardState } from '../layouts-shared/useClipboardState';
import { DASHBOARD_DROP_TARGET_KEY_ATTR } from '../types/DashboardDropTarget';
import { RowItem } from './RowItem';
import { RowItemRepeater } from './RowItemRepeater';
@@ -22,6 +24,38 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
const styles = useStyles2(getStyles);
const { hasCopiedRow } = useClipboardState();
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);
if (row && orchestrator) {
orchestrator.startRowDrag(row);
}
},
[rows, orchestrator]
);
const handleDragEnd = useCallback(
(result: DropResult) => {
// Stop tracking row drag in orchestrator
orchestrator?.stopRowDrag();
if (!result.destination) {
return;
}
if (result.destination.index === result.source.index) {
return;
}
model.moveRow(result.draggableId, result.source.index, result.destination.index);
},
[model, orchestrator]
);
if (soloPanelContext) {
return rows.map((row) => <RowWrapper row={row} manager={model} key={row.state.key!} />);
@@ -31,22 +65,18 @@ export function RowLayoutManagerRenderer({ model }: SceneComponentProps<RowsLayo
return (
<DragDropContext
onBeforeCapture={handleBeforeCapture}
onBeforeDragStart={(start) => model.forceSelectRow(start.draggableId)}
onDragEnd={(result) => {
if (!result.destination) {
return;
}
if (result.destination.index === result.source.index) {
return;
}
model.moveRow(result.draggableId, result.source.index, result.destination.index);
}}
onDragEnd={handleDragEnd}
>
<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 ? { [DASHBOARD_DROP_TARGET_KEY_ATTR]: key } : {})}
>
{rows.map((row) => (
<RowWrapper row={row} manager={model} key={row.state.key!} />
))}
@@ -27,6 +27,8 @@ import { AutoGridItem } from '../layout-auto-grid/AutoGridItem';
import { AutoGridLayout } from '../layout-auto-grid/AutoGridLayout';
import { AutoGridLayoutManager } from '../layout-auto-grid/AutoGridLayoutManager';
import { DashboardGridItem } from '../layout-default/DashboardGridItem';
import { RowItem } from '../layout-rows/RowItem';
import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
import { clearClipboard } from '../layouts-shared/paste';
import { scrollCanvasElementIntoView } from '../layouts-shared/scrollCanvasElementIntoView';
import { BulkActionElement } from '../types/BulkActionElement';
@@ -230,6 +232,19 @@ export class TabItem
if (isDashboardLayoutGrid(layout)) {
layout.addGridItem(gridItem);
} else if (layout instanceof RowsLayoutManager) {
// For RowsLayoutManager, add to the first row's layout
const firstRow = layout.state.rows[0];
if (firstRow) {
const rowLayout = firstRow.getLayout();
if (isDashboardLayoutGrid(rowLayout)) {
rowLayout.addGridItem(gridItem);
} else {
const warningMessage = 'First row layout does not support addGridItem';
console.warn(warningMessage);
logWarning(warningMessage);
}
}
} else {
const warningMessage = 'Layout manager does not support addGridItem';
console.warn(warningMessage);
@@ -243,6 +258,48 @@ export class TabItem
}
}
/**
* Accept a dropped row into this tab.
* If the tab doesn't have a RowsLayoutManager, convert the layout first.
*/
public acceptDroppedRow(row: RowItem): void {
const currentLayout = this.getLayout();
// Clear the parent reference from the row before adding to new layout
row.clearParent();
if (currentLayout instanceof RowsLayoutManager) {
// Already has a RowsLayoutManager, just add the row
currentLayout.addNewRow(row);
} else {
// Need to convert the layout to RowsLayoutManager
let rowsLayout: RowsLayoutManager;
// If the current layout is empty, just create a new RowsLayoutManager with only the dropped row
if (currentLayout.getVizPanels().length === 0) {
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.setState({ rows: [...rowsLayout.state.rows, row] });
}
// Clear the parent reference from the old layout
currentLayout.clearParent();
// Switch to the new rows layout
this.setState({ layout: rowsLayout });
}
// Ensure this tab is active after the drop
const parentLayout = this.getParentLayout();
if (parentLayout.state.currentTabSlug !== this.getSlug()) {
parentLayout.setState({ currentTabSlug: this.getSlug() });
}
}
public getParentLayout(): TabsLayoutManager {
return sceneGraph.getAncestor(this, TabsLayoutManager);
}
@@ -11,7 +11,7 @@ import { useIsConditionallyHidden } from '../../conditional-rendering/hooks/useI
import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { useDashboardState } from '../../utils/utils';
import { useSoloPanelContext } from '../SoloPanelContext';
import { isDashboardLayoutGrid } from '../types/DashboardLayoutGrid';
import { DASHBOARD_DROP_TARGET_KEY_ATTR } from '../types/DashboardDropTarget';
import { TabItem } from './TabItem';
@@ -92,7 +92,7 @@ export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
onSelect?.(evt);
}}
label={titleInterpolated}
data-dashboard-drop-target-key={isDashboardLayoutGrid(layout) ? model.state.key : undefined}
data-tab-activation-key={key}
{...titleCollisionProps}
/>
</div>
@@ -126,7 +126,7 @@ export function TabItemLayoutRenderer({ tab, isEditing }: TabItemLayoutRendererP
return (
<TabContent
className={cx(styles.tabContentContainer, isEditing && conditionalRenderingClass)}
data-dashboard-drop-target-key={key}
{...{ [DASHBOARD_DROP_TARGET_KEY_ATTR]: key }}
>
<layout.Component model={layout} />
{isEditing && conditionalRenderingOverlay}
@@ -1,10 +1,18 @@
import { SceneObject, SceneGridItemLike } from '@grafana/scenes';
/** Data attribute used to identify auto grid items as drop targets */
export const AUTO_GRID_ITEM_DROP_TARGET_ATTR = 'data-auto-grid-item-drop-target';
/** Data attribute used to identify dashboard layout elements as drop targets */
export const DASHBOARD_DROP_TARGET_KEY_ATTR = 'data-dashboard-drop-target-key';
export interface DashboardDropTarget extends SceneObject {
isDashboardDropTarget: Readonly<true>;
setIsDropTarget?(isDropTarget: boolean): void;
draggedGridItemOutside?(gridItem: SceneGridItemLike): void;
draggedGridItemInside?(gridItem: SceneGridItemLike): void;
draggedGridItemInside?(gridItem: SceneGridItemLike, position?: number): void;
/** Set the position where a placeholder should be shown for external drops */
setDropPosition?(position: number | null): void;
}
export function isDashboardDropTarget(scene: SceneObject): scene is DashboardDropTarget {
+1
View File
@@ -5337,6 +5337,7 @@
},
"header-hidden-tooltip": "Row header only visible in edit mode",
"name": "Rows",
"new-row-title": "New row",
"row": {
"collapse": "Collapse row",
"expand": "Expand row",