From b444e42f0503d71f484f6464e803e08bac0ef262 Mon Sep 17 00:00:00 2001 From: oscarkilhed Date: Wed, 7 Jan 2026 10:25:33 +0100 Subject: [PATCH] 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 --- .../scene/DashboardLayoutOrchestrator.tsx | 83 ++++++++++++++++++- .../layout-auto-grid/AutoGridItemRenderer.tsx | 19 +++-- .../AutoGridLayoutManager.tsx | 63 +++++++++++++- .../AutoGridLayoutRenderer.tsx | 39 ++++++++- .../scene/layout-tabs/TabItem.tsx | 3 + .../scene/types/DashboardDropTarget.ts | 4 +- 6 files changed, 198 insertions(+), 13 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/DashboardLayoutOrchestrator.tsx b/public/app/features/dashboard-scene/scene/DashboardLayoutOrchestrator.tsx index 9b348e81abc..158a6a1e857 100644 --- a/public/app/features/dashboard-scene/scene/DashboardLayoutOrchestrator.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardLayoutOrchestrator.tsx @@ -16,6 +16,7 @@ import { import { createPointerDistance, useStyles2 } from '@grafana/ui'; import { DashboardScene } from './DashboardScene'; +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'; @@ -75,6 +76,10 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase el.getAttribute('data-auto-grid-item-drop-target')); + const targetKey = targetElement?.getAttribute('data-auto-grid-item-drop-target'); + + 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 { diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx index 15b7e82ae36..0aa79b587c6 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx @@ -2,7 +2,7 @@ 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'; @@ -12,6 +12,7 @@ import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelConte import { getIsLazy } from '../layouts-shared/utils'; 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) { @@ -22,6 +23,10 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps 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 @@ -31,14 +36,14 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps { const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay, renderHidden] = @@ -47,7 +52,7 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps @@ -94,6 +99,8 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps @@ -103,7 +110,7 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps {repeatedPanels.map((item, idx) => ( ))} diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx index 74ce363a970..76e395c007f 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutManager.tsx @@ -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 implements DashboardLayoutGrid { +export class AutoGridLayoutManager + extends SceneObjectBase + 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 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) { diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutRenderer.tsx index a45e4507ed6..28e73cb09c1 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridLayoutRenderer.tsx @@ -18,7 +18,7 @@ export function AutoGridLayoutRenderer({ model }: SceneComponentProps ); } + // Build children with placeholder inserted at dropPosition + const renderChildren = () => { + if (dropPosition === null || dropPosition === undefined) { + return children.map((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(); + } + if (i < children.length) { + const item = children[i]; + result.push(); + } + } + + return result; + }; + return (
- {children.map((item) => ( - - ))} + {renderChildren()} {showCanvasActions && }
); } +function DropPlaceholder({ styles }: { styles: ReturnType }) { + return
; +} + const getStyles = (theme: GrafanaTheme2, state: AutoGridLayoutState) => ({ container: css({ display: 'grid', @@ -72,4 +97,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: state.autoRows || '320px', + }), }); diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx index c24ef88cc4f..962fd272e96 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx @@ -252,6 +252,9 @@ export class TabItem 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); diff --git a/public/app/features/dashboard-scene/scene/types/DashboardDropTarget.ts b/public/app/features/dashboard-scene/scene/types/DashboardDropTarget.ts index 8e28d5be9f0..1d97b638d5f 100644 --- a/public/app/features/dashboard-scene/scene/types/DashboardDropTarget.ts +++ b/public/app/features/dashboard-scene/scene/types/DashboardDropTarget.ts @@ -4,7 +4,9 @@ export interface DashboardDropTarget extends SceneObject { isDashboardDropTarget: Readonly; 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 {