Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccc52094a9 | |||
| 47bb27a977 | |||
| d599a2b125 | |||
| 9992da3cc8 | |||
| 5256a5e83e | |||
| 337560a993 | |||
| ad10039d41 | |||
| 81880ffb2a | |||
| 792a26800b | |||
| 7ea4dd3744 | |||
| b444e42f05 | |||
| 381a67243d | |||
| 2873009b47 |
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
+15
-7
@@ -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);
|
||||
|
||||
+62
-1
@@ -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>) {
|
||||
|
||||
+36
-4
@@ -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[] = [];
|
||||
|
||||
|
||||
+44
-14
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user