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
This commit is contained in:
oscarkilhed
2026-01-07 10:25:33 +01:00
parent 381a67243d
commit b444e42f05
6 changed files with 198 additions and 13 deletions
@@ -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<DashboardLayout
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({});
@@ -131,6 +136,7 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
// Capture these before cleanup since setTimeout runs after cleanup
const sourceDropTarget = this._sourceDropTarget;
const lastDropTarget = this._lastDropTarget;
const dropPosition = this._currentDropPosition;
// Handle cross-layout or cross-tab drop
if (sourceDropTarget !== lastDropTarget || wasDetached) {
@@ -142,7 +148,8 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
if (!wasDetached) {
sourceDropTarget?.draggedGridItemOutside?.(gridItem);
}
lastDropTarget?.draggedGridItemInside?.(gridItem);
// Pass drop position for precise placement (AutoGrid uses this)
lastDropTarget?.draggedGridItemInside?.(gridItem, dropPosition ?? undefined);
} else {
const warningMessage = 'No grid item to drag';
console.warn(warningMessage);
@@ -156,6 +163,7 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
this._clearTabActivationTimer();
this._clearDragPreview();
this._clearDropPosition();
this._lastDropTarget?.setIsDropTarget?.(false);
this._lastDropTarget = null;
this._sourceDropTarget = null;
@@ -555,10 +563,13 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
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;
@@ -566,6 +577,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('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 {
@@ -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<AutoGridItem>) {
@@ -22,6 +23,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
@@ -31,14 +36,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] =
@@ -47,7 +52,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, ['data-auto-grid-item-drop-target']: showDropTarget ? key : undefined }
: {})}
className={cx(isConditionallyHidden && !isEditing && styles.hidden)}
>
@@ -94,6 +99,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 (
<>
@@ -103,7 +110,7 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
addDndContainer={true}
key={body.state.key!}
isDragged={isDragged}
isDragging={isDragging}
showDropTarget={showDropTarget}
/>
{repeatedPanels.map((item, idx) => (
<Wrapper
@@ -112,7 +119,7 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
addDndContainer={false}
key={item.state.key!}
isDragged={isDragged}
isDragging={isDragging}
showDropTarget={showDropTarget}
isRepeat={true}
/>
))}
@@ -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>) {
@@ -18,7 +18,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 +31,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}
data-dashboard-drop-target-key={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 +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',
}),
});
@@ -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);
@@ -4,7 +4,9 @@ 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 {