Dynamic Dashboard: Drag and drop between grids, rows and tabs (#102714)

pull/102521/head^2
Bogdan Matei 4 months ago committed by GitHub
parent b06556914c
commit 83bf06d435
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 56
      packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx
  2. 22
      packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts
  3. 113
      public/app/features/dashboard-scene/scene/DashboardLayoutOrchestrator.tsx
  4. 9
      public/app/features/dashboard-scene/scene/DashboardScene.tsx
  5. 3
      public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx
  6. 18
      public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx
  7. 10
      public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx
  8. 46
      public/app/features/dashboard-scene/scene/layout-manager/DropZonePlaceholder.tsx
  9. 161
      public/app/features/dashboard-scene/scene/layout-manager/LayoutOrchestrator.tsx
  10. 63
      public/app/features/dashboard-scene/scene/layout-manager/utils.ts
  11. 85
      public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx
  12. 67
      public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemRenderer.tsx
  13. 230
      public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayout.tsx
  14. 12
      public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx
  15. 66
      public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutRenderer.tsx
  16. 31
      public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx
  17. 6
      public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx
  18. 38
      public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx
  19. 6
      public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx
  20. 12
      public/app/features/dashboard-scene/scene/types/DashboardDropTarget.ts
  21. 34
      public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts
  22. 5
      public/app/features/dashboard-scene/utils/utils.ts

@ -151,7 +151,7 @@ export function PanelChrome({
const panelContentId = useId();
const panelTitleId = useId().replace(/:/g, '_');
const { isSelected, onSelect, isSelectable } = useElementSelection(selectionId);
const pointerDownPos = useRef<{ screenX: number; screenY: number }>({ screenX: 0, screenY: 0 });
const pointerDownLocation = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const hasHeader = !hoverHeader;
@ -198,35 +198,37 @@ export function PanelChrome({
// Handle drag & selection events
// Mainly the tricky bit of differentiating between dragging and selecting
const onPointerUp = React.useCallback(
(evt: React.PointerEvent) => {
const distance = Math.hypot(
evt.clientX - pointerDownLocation.current.x,
evt.clientY - pointerDownLocation.current.y
);
if (
distance > 10 ||
(dragClassCancel && evt.target instanceof Element && evt.target.closest(`.${dragClassCancel}`))
) {
return;
}
// setTimeout is needed here because onSelect stops the event propagation
// By doing so, the event won't get to the document and drag will never be stopped
setTimeout(() => onSelect?.(evt));
},
[dragClassCancel, onSelect]
);
const onPointerUp = (evt: React.PointerEvent) => {
evt.stopPropagation();
const distance = Math.hypot(
pointerDownPos.current.screenX - evt.screenX,
pointerDownPos.current.screenY - evt.screenY
);
pointerDownPos.current = { screenX: 0, screenY: 0 };
// If we are dragging some distance or clicking on elements that should cancel dragging (panel menu, etc)
if (
distance > 10 ||
(dragClassCancel && evt.target instanceof HTMLElement && evt.target.closest(`.${dragClassCancel}`))
) {
return;
}
onSelect?.(evt);
};
const onPointerDown = (evt: React.PointerEvent) => {
evt.stopPropagation();
const onPointerDown = React.useCallback(
(evt: React.PointerEvent) => {
evt.stopPropagation();
pointerDownPos.current = { screenX: evt.screenX, screenY: evt.screenY };
pointerDownLocation.current = { x: evt.clientX, y: evt.clientY };
onDragStart?.(evt);
};
onDragStart?.(evt);
},
[onDragStart]
);
const headerContent = (
<>

@ -105,5 +105,27 @@ export function getDashboardGridStyles(theme: GrafanaTheme2) {
opacity: 1,
},
},
// Universal style for marking drop targets when dragging between layouts
'.dashboard-drop-target': {
// Setting same options for hovered and not hovered to overwrite any conflicting styles
// There was a race condition with selectable elements styles
'&:is(:hover),&:not(:hover)': {
outline: `2px solid ${theme.colors.primary.border}`,
outlineOffset: '0px',
borderRadius: '2px',
},
},
// Body style for preventing selection when dragging
'.dashboard-draggable-transparent-selection': {
'*::selection': {
all: 'inherit',
},
},
'.react-draggable-dragging': {
opacity: 0.8,
},
});
}

@ -0,0 +1,113 @@
import { PointerEvent as ReactPointerEvent } from 'react';
import { sceneGraph, SceneObjectBase, SceneObjectRef, SceneObjectState, VizPanel } from '@grafana/scenes';
import { DashboardScene } from './DashboardScene';
import { DashboardDropTarget, isDashboardDropTarget } from './types/DashboardDropTarget';
interface DashboardLayoutOrchestratorState extends SceneObjectState {
draggingPanel?: SceneObjectRef<VizPanel>;
}
export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayoutOrchestratorState> {
private _sourceDropTarget: DashboardDropTarget | null = null;
private _lastDropTarget: DashboardDropTarget | null = null;
public constructor() {
super({});
this._onPointerMove = this._onPointerMove.bind(this);
this._stopDraggingSync = this._stopDraggingSync.bind(this);
this.addActivationHandler(() => this._activationHandler());
}
private _activationHandler() {
return () => {
document.body.removeEventListener('pointermove', this._onPointerMove);
document.body.removeEventListener('pointerup', this._stopDraggingSync);
};
}
public startDraggingSync(_evt: ReactPointerEvent, panel: VizPanel): void {
const dropTarget = sceneGraph.findObject(panel, isDashboardDropTarget);
if (!dropTarget || !isDashboardDropTarget(dropTarget)) {
return;
}
this._sourceDropTarget = dropTarget;
this._lastDropTarget = dropTarget;
document.body.addEventListener('pointermove', this._onPointerMove);
document.body.addEventListener('pointerup', this._stopDraggingSync);
this.setState({ draggingPanel: panel.getRef() });
}
private _stopDraggingSync(_evt: PointerEvent) {
const panel = this.state.draggingPanel?.resolve();
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.
setTimeout(() => {
this._sourceDropTarget?.draggedPanelOutside?.(panel!);
this._lastDropTarget?.draggedPanelInside?.(panel!);
});
}
document.body.removeEventListener('pointermove', this._onPointerMove);
document.body.removeEventListener('pointerup', this._stopDraggingSync);
this.setState({ draggingPanel: undefined });
}
private _onPointerMove(evt: PointerEvent) {
const dropTarget = this._getDropTargetUnderMouse(evt) ?? this._sourceDropTarget;
if (!dropTarget) {
return;
}
if (dropTarget !== this._lastDropTarget) {
this._lastDropTarget?.setIsDropTarget?.(false);
this._lastDropTarget = dropTarget;
if (dropTarget !== this._sourceDropTarget) {
dropTarget.setIsDropTarget?.(true);
}
}
}
private _getDashboard(): DashboardScene {
if (!(this.parent instanceof DashboardScene)) {
throw new Error('Parent is not a DashboardScene');
}
return this.parent;
}
private _getDropTargetUnderMouse(evt: MouseEvent): DashboardDropTarget | null {
const key = document
.elementsFromPoint(evt.clientX, evt.clientY)
?.find((element) => {
const key = element.getAttribute('data-dashboard-drop-target-key');
return !!key && key !== this._sourceDropTarget?.state.key;
})
?.getAttribute('data-dashboard-drop-target-key');
if (!key) {
return null;
}
const sceneObject = sceneGraph.findByKey(this._getDashboard(), key);
if (!sceneObject || !isDashboardDropTarget(sceneObject)) {
return null;
}
return sceneObject;
}
}

@ -62,6 +62,7 @@ import { SchemaV2EditorDrawer } from '../v2schema/SchemaV2EditorDrawer';
import { AddLibraryPanelDrawer } from './AddLibraryPanelDrawer';
import { DashboardControls } from './DashboardControls';
import { DashboardLayoutOrchestrator } from './DashboardLayoutOrchestrator';
import { DashboardSceneRenderer } from './DashboardSceneRenderer';
import { DashboardSceneUrlSync } from './DashboardSceneUrlSync';
import { LibraryPanelBehavior } from './LibraryPanelBehavior';
@ -70,8 +71,6 @@ import { isUsingAngularDatasourcePlugin, isUsingAngularPanelPlugin } from './ang
import { setupKeyboardShortcuts } from './keyboardShortcuts';
import { DashboardGridItem } from './layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
import { DropZonePlaceholder } from './layout-manager/DropZonePlaceholder';
import { LayoutOrchestrator } from './layout-manager/LayoutOrchestrator';
import { LayoutRestorer } from './layouts-shared/LayoutRestorer';
import { addNewRowTo, addNewTabTo } from './layouts-shared/addNew';
import { DashboardLayoutManager } from './types/DashboardLayoutManager';
@ -136,7 +135,7 @@ export interface DashboardSceneState extends SceneObjectState {
editPane: DashboardEditPane;
scopesBridge: SceneScopesBridge | undefined;
/** Manages dragging/dropping of layout items */
layoutOrchestrator: LayoutOrchestrator;
layoutOrchestrator?: DashboardLayoutOrchestrator;
}
export class DashboardScene extends SceneObjectBase<DashboardSceneState> implements LayoutParent {
@ -190,9 +189,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
...state,
editPane: new DashboardEditPane(),
scopesBridge: config.featureToggles.scopeFilters ? new SceneScopesBridge({}) : undefined,
layoutOrchestrator: new LayoutOrchestrator({
placeholder: new DropZonePlaceholder({ top: 0, left: 0, width: 0, height: 0 }),
}),
layoutOrchestrator: config.featureToggles.dashboardNewLayouts ? new DashboardLayoutOrchestrator() : undefined,
});
this.serializer =

@ -26,7 +26,6 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
scopesBridge,
layoutOrchestrator,
} = model.useState();
const { placeholder } = layoutOrchestrator.useState();
const { type } = useParams();
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
@ -74,7 +73,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
return (
<>
{placeholder && <placeholder.Component model={placeholder} />}
{layoutOrchestrator && <layoutOrchestrator.Component model={layoutOrchestrator} />}
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Custom}>
{scopesBridge && <scopesBridge.Component model={scopesBridge} />}
{editPanel && <editPanel.Component model={editPanel} />}

@ -1,5 +1,4 @@
import { isEqual } from 'lodash';
import { createRef } from 'react';
import { Unsubscribable } from 'rxjs';
import {
@ -21,8 +20,7 @@ import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components
import { getCloneKey } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { Point, Rect } from '../layout-manager/utils';
import { DashboardLayoutItem, IntermediateLayoutItem } from '../types/DashboardLayoutItem';
import { DashboardLayoutItem } from '../types/DashboardLayoutItem';
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
import { getDashboardGridItemOptions } from './DashboardGridItemEditor';
@ -54,8 +52,6 @@ export class DashboardGridItem
private _gridSizeSub: Unsubscribable | undefined;
public containerRef = createRef<HTMLElement>();
public constructor(state: DashboardGridItemState) {
super(state);
@ -249,16 +245,4 @@ export class DashboardGridItem
public isRepeated(): boolean {
return this.state.variableName !== undefined;
}
public toIntermediate(): IntermediateLayoutItem {
throw new Error('Method not implemented.');
}
public distanceToPoint?(point: Point): number {
throw new Error('Method not implemented.');
}
public boundingBox?(): Rect | undefined {
throw new Error('Method not implemented.');
}
}

@ -10,6 +10,7 @@ import {
SceneComponentProps,
SceneGridItemLike,
useSceneObjectState,
SceneGridLayoutDragStartEvent,
} from '@grafana/scenes';
import { GRID_COLUMN_COUNT } from 'app/core/constants';
import { t } from 'app/core/internationalization';
@ -30,6 +31,7 @@ import {
getVizPanelKeyForPanelId,
getGridItemKeyForPanelId,
useDashboard,
getLayoutOrchestratorFor,
} from '../../utils/utils';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
@ -72,6 +74,14 @@ export class DefaultGridLayoutManager
}
private _activationHandler() {
if (config.featureToggles.dashboardNewLayouts) {
this._subs.add(
this.subscribeToEvent(SceneGridLayoutDragStartEvent, ({ payload: { evt, panel } }) =>
getLayoutOrchestratorFor(this)?.startDraggingSync(evt, panel)
)
);
}
this._subs.add(
this.state.grid.subscribeToState(({ children: newChildren }, { children: prevChildren }) => {
if (newChildren.length === prevChildren.length) {

@ -1,46 +0,0 @@
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { Portal, useStyles2 } from '@grafana/ui';
interface DropZonePlaceholderState extends SceneObjectState {
width: number;
height: number;
top: number;
left: number;
}
export class DropZonePlaceholder extends SceneObjectBase<DropZonePlaceholderState> {
static Component = ({ model }: SceneComponentProps<DropZonePlaceholder>) => {
const { width, height, left, top } = model.useState();
const styles = useStyles2(getStyles);
return (
<Portal>
<div
className={cx(styles.placeholder, {
[styles.visible]: width > 0 && height > 0,
})}
style={{ width, height, transform: `translate(${left}px, ${top}px)` }}
/>
</Portal>
);
};
}
const getStyles = (theme: GrafanaTheme2) => ({
placeholder: css({
visibility: 'hidden',
position: 'fixed',
top: 0,
left: 0,
zIndex: -1,
pointerEvents: 'none',
background: theme.colors.primary.transparent,
boxShadow: `0 0 4px ${theme.colors.primary.border}`,
}),
visible: css({
visibility: 'visible',
}),
});

@ -1,161 +0,0 @@
import { SceneObjectState, SceneObjectBase, SceneObjectRef, sceneGraph, VizPanel } from '@grafana/scenes';
import { DashboardLayoutItem, isDashboardLayoutItem } from '../types/DashboardLayoutItem';
import { DropZonePlaceholder } from './DropZonePlaceholder';
import { closestOfType, DropZone, isSceneLayoutWithDragAndDrop, Point, SceneLayoutWithDragAndDrop } from './utils';
interface LayoutOrchestratorState extends SceneObjectState {
activeLayoutItemRef?: SceneObjectRef<DashboardLayoutItem>;
placeholder?: DropZonePlaceholder;
}
export class LayoutOrchestrator extends SceneObjectBase<LayoutOrchestratorState> {
/** Offset from top-left corner of drag handle. */
public dragOffset = { top: 0, left: 0 };
/** The drop zone closest to the current mouse position while dragging. */
public activeDropZone: (DropZone & { layout: SceneObjectRef<SceneLayoutWithDragAndDrop> }) | undefined;
private _sceneLayouts: SceneLayoutWithDragAndDrop[] = [];
/** Used in `ResponsiveGridLayout`'s `onPointerDown` method */
public onDragStart = (e: PointerEvent, panel: VizPanel) => {
const closestLayoutItem = closestOfType(panel, isDashboardLayoutItem);
if (!closestLayoutItem) {
console.warn('Unable to find layout item ancestor in panel hierarchy.');
return;
}
if (!(e.target instanceof HTMLElement)) {
console.warn('Target is not a HTML element.');
return;
}
this._sceneLayouts = sceneGraph
.findAllObjects(this.getRoot(), isSceneLayoutWithDragAndDrop)
.filter(isSceneLayoutWithDragAndDrop);
document.body.setPointerCapture(e.pointerId);
const targetRect = e.target.getBoundingClientRect();
this.dragOffset = { top: e.y - targetRect.top, left: e.x - targetRect.left };
closestLayoutItem.containerRef.current?.style.setProperty('--x-pos', `${e.x}px`);
closestLayoutItem.containerRef.current?.style.setProperty('--y-pos', `${e.y}px`);
const state: Partial<LayoutOrchestratorState> = { activeLayoutItemRef: closestLayoutItem.getRef() };
this._adjustXY({ x: e.x, y: e.y }, closestLayoutItem);
this.activeDropZone = this.findClosestDropZone({ x: e.clientX, y: e.clientY });
if (this.activeDropZone) {
state.placeholder = new DropZonePlaceholder({
top: this.activeDropZone.top,
left: this.activeDropZone.left,
width: this.activeDropZone.right - this.activeDropZone.left,
height: this.activeDropZone.bottom - this.activeDropZone.top,
});
}
this.setState(state);
document.addEventListener('pointermove', this.onDrag);
document.addEventListener('pointerup', this.onDragEnd);
document.body.classList.add('dragging-active');
};
/** Called every tick while a panel is actively being dragged */
public onDrag = (e: PointerEvent) => {
const layoutItemContainer = this.state.activeLayoutItemRef?.resolve().containerRef.current;
if (!layoutItemContainer) {
this.onDragEnd(e);
return;
}
const cursorPos: Point = { x: e.clientX, y: e.clientY };
this._adjustXY(cursorPos);
const closestDropZone = this.findClosestDropZone(cursorPos);
if (!dropZonesAreEqual(this.activeDropZone, closestDropZone)) {
this.activeDropZone = closestDropZone;
if (this.activeDropZone) {
this.setState({
placeholder: new DropZonePlaceholder({
top: this.activeDropZone.top,
left: this.activeDropZone.left,
width: this.activeDropZone.right - this.activeDropZone.left,
height: this.activeDropZone.bottom - this.activeDropZone.top,
}),
});
}
}
};
/**
* Called when the panel drag operation ends.
* Clears up event listeners and any scratch state.
*/
public onDragEnd = (e: PointerEvent) => {
document.removeEventListener('pointermove', this.onDrag);
document.removeEventListener('pointerup', this.onDragEnd);
document.body.releasePointerCapture(e.pointerId);
document.body.classList.remove('dragging-active');
const activeLayoutItem = this.state.activeLayoutItemRef?.resolve();
const activeLayoutItemContainer = activeLayoutItem?.containerRef.current;
const targetLayout = this.activeDropZone?.layout.resolve();
if (!activeLayoutItem) {
console.error('No active layout item');
return;
} else if (!targetLayout) {
console.error('No target layout');
return;
}
this.moveLayoutItem(activeLayoutItem, targetLayout);
this.setState({ activeLayoutItemRef: undefined, placeholder: undefined });
this.activeDropZone = undefined;
activeLayoutItemContainer?.removeAttribute('style');
};
/** Moves layoutItem from its current layout to targetLayout to the location of the current placeholder.
* Throws if layoutItem does not belong to any layout. */
private moveLayoutItem(layoutItem: DashboardLayoutItem, targetLayout: SceneLayoutWithDragAndDrop) {
const sourceLayout = closestOfType(layoutItem, isSceneLayoutWithDragAndDrop);
if (!sourceLayout) {
throw new Error(`Layout item with key "${layoutItem.state.key}" does not belong to any layout`);
}
sourceLayout.removeLayoutItem(layoutItem);
targetLayout.importLayoutItem(layoutItem);
}
public findClosestDropZone(p: Point) {
let closestDropZone: (DropZone & { layout: SceneObjectRef<SceneLayoutWithDragAndDrop> }) | undefined = undefined;
let closestDistance = Number.MAX_VALUE;
for (const layout of this._sceneLayouts) {
const curClosestDropZone = layout.closestDropZone(p);
if (curClosestDropZone.distanceToPoint < closestDistance) {
closestDropZone = { ...curClosestDropZone, layout: layout.getRef() };
closestDistance = curClosestDropZone.distanceToPoint;
}
}
return closestDropZone;
}
private _adjustXY(p: Point, activeLayoutItem = this.state.activeLayoutItemRef?.resolve()) {
const container = activeLayoutItem?.containerRef.current;
container?.style.setProperty('--x-pos', `${p.x}px`);
container?.style.setProperty('--y-pos', `${p.y}px`);
}
}
function dropZonesAreEqual(a?: DropZone, b?: DropZone) {
const dims: Array<keyof DropZone> = ['top', 'left', 'bottom', 'right'];
return a && b && dims.every((dim) => b[dim] === a[dim]);
}

@ -1,63 +0,0 @@
import { SceneLayout, SceneObject } from '@grafana/scenes';
import { DashboardLayoutItem } from '../types/DashboardLayoutItem';
export interface Point {
x: number;
y: number;
}
export interface Rect {
top: number;
left: number;
bottom: number;
right: number;
}
export interface DropZone extends Rect {
/* The two-dimensional euclidean distance, in pixels, between the drop zone and some reference point (usually cursor position) */
distanceToPoint: number;
}
export interface SceneLayoutWithDragAndDrop extends SceneLayout {
closestDropZone(cursorPosition: Point): DropZone;
importLayoutItem(layoutItem: DashboardLayoutItem): void;
removeLayoutItem(layoutItem: DashboardLayoutItem): void;
}
// todo@kay: Not the most robust interface check, should make more robust.
export function isSceneLayoutWithDragAndDrop(o: SceneObject): o is SceneLayoutWithDragAndDrop {
return (
'isDraggable' in o &&
'closestDropZone' in o &&
typeof o.isDraggable === 'function' &&
typeof o.closestDropZone === 'function'
);
}
/** Walks up the scene graph, returning the first non-undefined result of `extract` */
export function getClosest<T>(sceneObject: SceneObject, extract: (s: SceneObject) => T | undefined): T | undefined {
let curSceneObject: SceneObject | undefined = sceneObject;
let extracted: T | undefined = undefined;
while (curSceneObject && !extracted) {
extracted = extract(curSceneObject);
curSceneObject = curSceneObject.parent;
}
return extracted;
}
/** Walks up the scene graph, returning the first non-undefined result of `extract` */
export function closestOfType<T extends SceneObject>(
sceneObject: SceneObject,
objectIsOfType: (s: SceneObject) => s is T
): T | undefined {
let curSceneObject: SceneObject | undefined = sceneObject;
while (curSceneObject && !objectIsOfType(curSceneObject)) {
curSceneObject = curSceneObject.parent;
}
return curSceneObject;
}

@ -1,5 +1,4 @@
import { isEqual } from 'lodash';
import { createRef } from 'react';
import {
CustomVariable,
@ -19,12 +18,12 @@ import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components
import { ConditionalRendering } from '../../conditional-rendering/ConditionalRendering';
import { getCloneKey } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils';
import { Point, Rect } from '../layout-manager/utils';
import { DashboardLayoutItem, IntermediateLayoutItem } from '../types/DashboardLayoutItem';
import { DashboardLayoutItem } from '../types/DashboardLayoutItem';
import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
import { getOptions } from './ResponsiveGridItemEditor';
import { AutoGridItemRenderer } from './ResponsiveGridItemRenderer';
import { AutoGridLayout } from './ResponsiveGridLayout';
export interface AutoGridItemState extends SceneObjectState {
body: VizPanel;
@ -37,14 +36,17 @@ export interface AutoGridItemState extends SceneObjectState {
export class AutoGridItem extends SceneObjectBase<AutoGridItemState> implements DashboardLayoutItem {
public static Component = AutoGridItemRenderer;
private _prevRepeatValues?: VariableValueSingle[];
protected _variableDependency = new VariableDependencyConfig(this, {
variableNames: this.state.variableName ? [this.state.variableName] : [],
onVariableUpdateCompleted: () => this.performRepeat(),
});
public readonly isDashboardLayoutItem = true;
public containerRef = createRef<HTMLDivElement>();
public cachedBoundingBox: Rect | undefined;
public _containerRef: HTMLDivElement | null = null;
private _prevRepeatValues?: VariableValueSingle[];
public constructor(state: AutoGridItemState) {
super({ ...state, conditionalRendering: state?.conditionalRendering ?? ConditionalRendering.createEmpty() });
@ -143,77 +145,26 @@ export class AutoGridItem extends SceneObjectBase<AutoGridItemState> implements
this.performRepeat();
}
public computeBoundingBox() {
const itemContainer = this.containerRef.current;
if (!itemContainer || this.state.isHidden) {
// We can't actually calculate the dimensions of the rendered grid item :(
throw new Error('Unable to compute bounding box.');
public getParentGrid(): AutoGridLayout {
if (!(this.parent instanceof AutoGridLayout)) {
throw new Error('Parent is not a ResponsiveGridLayout');
}
this.cachedBoundingBox = itemContainer.getBoundingClientRect();
return this.cachedBoundingBox;
return this.parent;
}
public distanceToPoint(point: Point): number {
if (!this.cachedBoundingBox) {
try {
this.cachedBoundingBox = this.computeBoundingBox();
} catch (err) {
// If we can't actually calculate the dimensions and position of the
// rendered grid item, it might as well be infinitely far away.
return Number.POSITIVE_INFINITY;
}
}
const { top, left, bottom, right } = this.cachedBoundingBox;
const corners: Point[] = [
{ x: left, y: top },
{ x: left, y: bottom },
{ x: right, y: top },
{ x: right, y: bottom },
];
const { distance } = closestPoint(point, ...corners);
return distance;
public setRef(ref: HTMLDivElement | null) {
this._containerRef = ref;
}
toIntermediate(): IntermediateLayoutItem {
const gridItem = this.containerRef.current;
if (!gridItem) {
throw new Error('Grid item not found. Unable to convert to intermediate representation');
}
// calculate origin and bounding box of layout item
const rect = gridItem.getBoundingClientRect();
public getBoundingBox(): { width: number; height: number; top: number; left: number } {
const rect = this._containerRef!.getBoundingClientRect();
return {
body: this.state.body,
origin: {
x: rect.left,
y: rect.top,
},
width: rect.width,
height: rect.height,
top: this._containerRef!.offsetTop,
left: this._containerRef!.offsetLeft,
};
}
}
// todo@kay: tests
function closestPoint(referencePoint: Point, ...points: Point[]): { point: Point; distance: number } {
let minDistance = Number.POSITIVE_INFINITY;
let closestPoint = points[0];
for (const currentPoint of points) {
const distance = euclideanDistance(referencePoint, currentPoint);
if (distance < minDistance) {
minDistance = distance;
closestPoint = currentPoint;
}
}
return { point: closestPoint, distance: minDistance };
}
function euclideanDistance(a: Point, b: Point): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}

@ -1,33 +1,80 @@
import { cx } from '@emotion/css';
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data/';
import { SceneComponentProps } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { useDashboardState, useIsConditionallyHidden } from '../../utils/utils';
import { AutoGridItem } from './ResponsiveGridItem';
import { DRAGGED_ITEM_HEIGHT, DRAGGED_ITEM_LEFT, DRAGGED_ITEM_TOP, DRAGGED_ITEM_WIDTH } from './ResponsiveGridLayout';
export interface AutoGridItemProps extends SceneComponentProps<AutoGridItem> {}
export function AutoGridItemRenderer({ model }: AutoGridItemProps) {
const { body } = model.useState();
export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem>) {
const { body, repeatedPanels, key } = model.useState();
const { draggingKey } = model.getParentGrid().useState();
const { isEditing } = useDashboardState(model);
const isConditionallyHidden = useIsConditionallyHidden(model);
const styles = useStyles2(getStyles);
if (isConditionallyHidden && !isEditing) {
return null;
}
return model.state.repeatedPanels ? (
const isDragging = !!draggingKey;
const isDragged = draggingKey === key;
return repeatedPanels ? (
<>
{model.state.repeatedPanels.map((item) => (
<div className={cx({ 'dashboard-visible-hidden-element': isConditionallyHidden })} key={item.state.key}>
{repeatedPanels.map((item) => (
<div
className={cx(isConditionallyHidden && 'dashboard-visible-hidden-element', styles.wrapper)}
key={item.state.key}
>
<item.Component model={item} />
</div>
))}
</>
) : (
<div className={cx({ 'dashboard-visible-hidden-element': isConditionallyHidden })}>
<body.Component model={body} />
<div ref={(ref) => model.setRef(ref)} data-auto-grid-item-drop-target={isDragging ? key : undefined}>
{isDragged && <div className={styles.draggedPlaceholder} />}
<div
className={cx(
isConditionallyHidden && 'dashboard-visible-hidden-element',
styles.wrapper,
isDragged && styles.draggedWrapper
)}
>
<body.Component model={body} />
</div>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
width: '100%',
height: '100%',
}),
wrapper: css({
width: '100%',
height: '100%',
position: 'relative',
}),
draggedWrapper: css({
position: 'absolute',
zIndex: 1000,
top: `var(${DRAGGED_ITEM_TOP})`,
left: `var(${DRAGGED_ITEM_LEFT})`,
width: `var(${DRAGGED_ITEM_WIDTH})`,
height: `var(${DRAGGED_ITEM_HEIGHT})`,
opacity: 0.8,
}),
draggedPlaceholder: css({
width: '100%',
height: '100%',
boxShadow: `0 0 ${theme.spacing(0.5)} ${theme.colors.primary.border}`,
background: `${theme.colors.primary.transparent}`,
zIndex: -1,
}),
});

@ -1,11 +1,8 @@
import { createRef, CSSProperties, PointerEvent } from 'react';
import { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
import { SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { SceneLayout, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
import { getDashboardSceneFor } from '../../utils/utils';
import { LayoutOrchestrator } from '../layout-manager/LayoutOrchestrator';
import { DropZone, Point, Rect, SceneLayoutWithDragAndDrop } from '../layout-manager/utils';
import { DashboardLayoutItem } from '../types/DashboardLayoutItem';
import { getLayoutOrchestratorFor } from '../../utils/utils';
import { AutoGridItem } from './ResponsiveGridItem';
import { AutoGridLayoutRenderer } from './ResponsiveGridLayoutRenderer';
@ -29,6 +26,9 @@ export interface AutoGridLayoutState extends SceneObjectState, AutoGridLayoutOpt
/** True when the items should be draggable */
isDraggable?: boolean;
/** The key of the item being dragged */
draggingKey?: string;
}
export interface AutoGridLayoutOptions {
@ -55,19 +55,17 @@ export interface AutoGridLayoutOptions {
justifyContent?: CSSProperties['justifyContent'];
}
export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> implements SceneLayoutWithDragAndDrop {
public layoutOrchestrator: LayoutOrchestrator | undefined;
export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> implements SceneLayout {
public static Component = AutoGridLayoutRenderer;
public containerRef = createRef<HTMLDivElement>();
public activeIndex: number | undefined;
public activeGridCell = { row: 1, column: 1 };
public columnCount = 1;
// maybe not needed?
public rowCount = 1;
public scrollPos: ReturnType<typeof closestScroll> | undefined;
private _containerRef: HTMLDivElement | null = null;
private _draggedGridItem: AutoGridItem | null = null;
private _initialGridItemPosition: {
pageX: number;
pageY: number;
top: number;
left: number;
} | null = null;
public constructor(state: Partial<AutoGridLayoutState>) {
super({
@ -79,12 +77,21 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
...state,
});
this.addActivationHandler(this.activationHandler);
this._onDragStart = this._onDragStart.bind(this);
this._onDragEnd = this._onDragEnd.bind(this);
this._onDrag = this._onDrag.bind(this);
this.addActivationHandler(() => this._activationHandler());
}
private activationHandler = () => {
this.layoutOrchestrator = getDashboardSceneFor(this).state.layoutOrchestrator;
};
private _activationHandler() {
return () => {
this._resetPanelPositionAndSize();
document.body.removeEventListener('pointermove', this._onDrag);
document.body.removeEventListener('pointerup', this._onDragEnd);
document.body.classList.remove('dashboard-draggable-transparent-selection');
};
}
public isDraggable(): boolean {
return this.state.isDraggable ?? false;
@ -98,110 +105,133 @@ export class AutoGridLayout extends SceneObjectBase<AutoGridLayoutState> impleme
return 'grid-drag-cancel';
}
public getDragHooks = () => {
return { onDragStart: this.onPointerDown };
};
public getDragHooks() {
return {
onDragStart: this._onDragStart,
};
}
public setRef(ref: HTMLDivElement | null) {
this._containerRef = ref;
}
private _canDrag(evt: ReactPointerEvent): boolean {
if (!this.isDraggable()) {
return false;
}
if (!(evt.target instanceof Element)) {
return false;
}
return !!evt.target.closest(`.${this.getDragClass()}`) && !evt.target.closest(`.${this.getDragClassCancel()}`);
}
public onPointerDown = (e: PointerEvent, panel: VizPanel) => {
const cannotDrag = this.cannotDrag(e.target);
if (cannotDrag || !this.layoutOrchestrator) {
// Start inside dragging
private _onDragStart(evt: ReactPointerEvent, panel: VizPanel) {
if (!this._canDrag(evt)) {
return;
}
e.preventDefault();
e.stopPropagation();
evt.preventDefault();
evt.stopPropagation();
// Refresh bounding boxes for all auto grid items
for (const child of this.state.children) {
child.computeBoundingBox();
if (!(panel.parent instanceof AutoGridItem)) {
throw new Error('Dragging wrong item');
}
this.scrollPos = closestScroll(this.containerRef.current);
this.layoutOrchestrator.onDragStart(e.nativeEvent, panel);
};
this._draggedGridItem = panel.parent;
private cannotDrag(el: EventTarget): boolean | Element {
const dragClass = this.getDragClass();
const dragCancelClass = this.getDragClassCancel();
const { top, left, width, height } = this._draggedGridItem.getBoundingBox();
this._initialGridItemPosition = { pageX: evt.pageX, pageY: evt.pageY, top, left: left };
this._updatePanelSize(width, height);
this._updatePanelPosition(top, left);
// cancel dragging if the element being interacted with has an ancestor with the drag cancel class set
// or if the drag class isn't set on an ancestor
return el instanceof Element && (el.closest(`.${dragCancelClass}`) || !el.closest(`.${dragClass}`));
this.setState({ draggingKey: this._draggedGridItem.state.key });
document.body.addEventListener('pointermove', this._onDrag);
document.body.addEventListener('pointerup', this._onDragEnd);
document.body.classList.add('dashboard-draggable-transparent-selection');
getLayoutOrchestratorFor(this)?.startDraggingSync(evt, panel);
}
/**
* Find the drop zone in this layout closest to the provided `point`.
* This gets called every tick while a layout item is being dragged, so we use the grid item's cached bbox,
* calculated whenever the layout changes, rather than calculating them every time the cursor position changes.
*/
public closestDropZone(point: Point): DropZone {
let minDistance = Number.POSITIVE_INFINITY;
let closestRect: Rect = { top: 0, bottom: 0, left: 0, right: 0 };
let closestIndex: number | undefined;
let closest = { row: 1, column: 1 };
this.state.children.forEach((gridItem, i) => {
let curColumn = i % this.columnCount;
let curRow = Math.floor(i / this.columnCount);
const distance = gridItem.distanceToPoint(point);
if (distance < minDistance && gridItem.cachedBoundingBox) {
minDistance = distance;
const { top, bottom, left, right } = gridItem.cachedBoundingBox;
closestRect = { top, bottom, left, right };
closestIndex = i;
// css grid rows/columns are 1-indexed
closest = { row: curRow + 1, column: curColumn + 1 };
}
});
// Stop inside dragging
private _onDragEnd() {
window.getSelection()?.removeAllRanges();
this._draggedGridItem = null;
this._initialGridItemPosition = null;
this._resetPanelPositionAndSize();
this.activeIndex = closestIndex;
this.activeGridCell = closest;
this.setState({ draggingKey: undefined });
return { ...closestRect, distanceToPoint: minDistance };
document.body.removeEventListener('pointermove', this._onDrag);
document.body.removeEventListener('pointerup', this._onDragEnd);
document.body.classList.remove('dashboard-draggable-transparent-selection');
}
public importLayoutItem(layoutItem: DashboardLayoutItem) {
const layoutItemIR = layoutItem.toIntermediate();
const layoutChildren = [...this.state.children];
// Handle inside drag moves
private _onDrag(evt: PointerEvent) {
if (!this._draggedGridItem || !this._initialGridItemPosition) {
this._onDragEnd();
return;
}
layoutItemIR.body.clearParent();
this._updatePanelPosition(
this._initialGridItemPosition.top + (evt.pageY - this._initialGridItemPosition.pageY),
this._initialGridItemPosition.left + (evt.pageX - this._initialGridItemPosition.pageX)
);
const newLayoutItem = new AutoGridItem({ body: layoutItemIR.body });
layoutChildren.splice(this.activeIndex ?? 0, 0, newLayoutItem);
const dropTargetGridItemKey = document
.elementsFromPoint(evt.clientX, evt.clientY)
?.find((element) => {
const key = element.getAttribute('data-auto-grid-item-drop-target');
this.setState({
children: layoutChildren,
});
return !!key && key !== this._draggedGridItem!.state.key;
})
?.getAttribute('data-auto-grid-item-drop-target');
newLayoutItem.activate();
if (dropTargetGridItemKey) {
this._onDragOverItem(dropTargetGridItemKey);
}
}
public removeLayoutItem(layoutItem: DashboardLayoutItem) {
this.setState({
children: this.state.children.filter((c) => c !== layoutItem),
});
// Handle dragging an item from the same grid over another item from the same grid
private _onDragOverItem(key: string) {
const children = [...this.state.children];
const draggedIdx = children.findIndex((child) => child === this._draggedGridItem);
const draggedOverIdx = children.findIndex((child) => child.state.key === key);
layoutItem.clearParent();
}
}
if (draggedIdx === -1 || draggedOverIdx === -1) {
return;
}
function closestScroll(el?: HTMLElement | null): {
scrollTop: number;
scrollTopMax: number;
wrapper?: HTMLElement | null;
} {
if (el && canScroll(el)) {
return { scrollTop: el.scrollTop, scrollTopMax: el.scrollHeight - el.clientHeight - 5, wrapper: el };
children.splice(draggedIdx, 1);
children.splice(draggedOverIdx, 0, this._draggedGridItem!);
this.setState({ children });
}
return el ? closestScroll(el.parentElement) : { scrollTop: 0, scrollTopMax: 0, wrapper: el };
}
private _updatePanelPosition(top: number, left: number) {
this._containerRef?.style.setProperty(DRAGGED_ITEM_TOP, `${top}px`);
this._containerRef?.style.setProperty(DRAGGED_ITEM_LEFT, `${left}px`);
}
function canScroll(el: HTMLElement) {
const oldScroll = el.scrollTop;
el.scrollTop = Number.MAX_SAFE_INTEGER;
const newScroll = el.scrollTop;
el.scrollTop = oldScroll;
private _updatePanelSize(width: number, height: number) {
this._containerRef?.style.setProperty(DRAGGED_ITEM_WIDTH, `${Math.floor(width)}px`);
this._containerRef?.style.setProperty(DRAGGED_ITEM_HEIGHT, `${Math.floor(height)}px`);
}
return newScroll > 0;
private _resetPanelPositionAndSize() {
this._containerRef?.style.removeProperty(DRAGGED_ITEM_TOP);
this._containerRef?.style.removeProperty(DRAGGED_ITEM_LEFT);
this._containerRef?.style.removeProperty(DRAGGED_ITEM_WIDTH);
this._containerRef?.style.removeProperty(DRAGGED_ITEM_HEIGHT);
}
}
export const DRAGGED_ITEM_TOP = '--responsive-grid-dragged-item-top';
export const DRAGGED_ITEM_LEFT = '--responsive-grid-dragged-item-left';
export const DRAGGED_ITEM_WIDTH = '--responsive-grid-dragged-item-width';
export const DRAGGED_ITEM_HEIGHT = '--responsive-grid-dragged-item-height';

@ -8,6 +8,7 @@ import { joinCloneKeys } from '../../utils/clone';
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
import {
forceRenderChildren,
getDashboardSceneFor,
getGridItemKeyForPanelId,
getPanelIdForVizPanel,
getVizPanelKeyForPanelId,
@ -72,14 +73,11 @@ export class AutoGridLayoutManager
layout:
state.layout ??
new AutoGridLayout({
isDraggable: true,
templateColumns: getTemplateColumnsTemplate(maxColumnCount, columnWidth),
autoRows: getAutoRowsTemplate(rowHeight, fillScreen),
}),
});
// @ts-ignore
this.state.layout.getDragClassCancel = () => 'drag-cancel';
this.state.layout.isDraggable = () => true;
}
public addPanel(vizPanel: VizPanel) {
@ -168,6 +166,7 @@ export class AutoGridLayoutManager
public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager {
return this.clone({
layout: this.state.layout.clone({
isDraggable: isSource && this.state.layout.state.isDraggable,
children: this.state.layout.state.children.map((gridItem) => {
if (gridItem instanceof AutoGridItem) {
// Get the original panel ID from the gridItem's key
@ -240,7 +239,10 @@ export class AutoGridLayoutManager
}
const layoutManager = AutoGridLayoutManager.createEmpty();
layoutManager.state.layout.setState({ children });
layoutManager.state.layout.setState({
children,
isDraggable: getDashboardSceneFor(layout).state.isEditing,
});
return layoutManager;
}

@ -1,5 +1,4 @@
import { css, cx } from '@emotion/css';
import { useEffect } from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { LazyLoader, SceneComponentProps } from '@grafana/scenes';
@ -14,64 +13,21 @@ export function AutoGridLayoutRenderer({ model }: SceneComponentProps<AutoGridLa
const styles = useStyles2(getStyles, model.state);
const { layoutOrchestrator } = getDashboardSceneFor(model).state;
const { activeLayoutItemRef } = layoutOrchestrator.useState();
const activeLayoutItem = activeLayoutItemRef?.resolve();
const currentLayoutIsActive = children.some((c) => c === activeLayoutItem);
useEffect(() => {
if (model.containerRef.current) {
const computedStyles = getComputedStyle(model.containerRef.current);
model.columnCount = computedStyles.gridTemplateColumns.split(' ').length;
model.rowCount = computedStyles.gridTemplateRows.split(' ').length;
// when the contents of a scrollable area are changed, most (all?) browsers
// seem to automatically adjust the scroll position
// this hack keeps the scroll position fixed
if (currentLayoutIsActive && model.scrollPos) {
model.scrollPos.wrapper?.scrollTo(0, model.scrollPos.scrollTop);
}
}
});
if (isHidden || !layoutOrchestrator) {
return null;
}
return (
<div className={styles.container} ref={model.containerRef}>
<div
style={{
gridRow: model.activeGridCell.row,
gridColumn: model.activeGridCell.column,
display: currentLayoutIsActive && model.activeIndex !== undefined ? 'grid' : 'none',
}}
/>
{children.map((item) => {
const Wrapper = isLazy ? LazyLoader : 'div';
const isDragging = activeLayoutItem === item;
return (
<Wrapper
key={item.state.key!}
className={cx(styles.wrapper, { [styles.dragging]: isDragging })}
style={
isDragging && layoutOrchestrator && item.cachedBoundingBox
? {
width: item.cachedBoundingBox.right - item.cachedBoundingBox.left,
height: item.cachedBoundingBox.bottom - item.cachedBoundingBox.top,
// adjust the panel position to mouse position
translate: `${-layoutOrchestrator.dragOffset.left}px ${-layoutOrchestrator.dragOffset.top}px`,
// adjust the panel position on the screen
transform: `translate(var(--x-pos), var(--y-pos))`,
}
: {}
}
ref={item.containerRef}
>
<item.Component model={item} />
</Wrapper>
);
})}
<div className={styles.container} ref={(ref) => model.setRef(ref)}>
{children.map((item) =>
isLazy ? (
<LazyLoader key={item.state.key!} className={styles.container}>
<item.Component key={item.state.key} model={item} />
</LazyLoader>
) : (
<item.Component key={item.state.key} model={item} />
)
)}
</div>
);
}

@ -1,4 +1,11 @@
import { sceneGraph, SceneObject, SceneObjectBase, SceneObjectState, VariableDependencyConfig } from '@grafana/scenes';
import {
sceneGraph,
SceneObject,
SceneObjectBase,
SceneObjectState,
VariableDependencyConfig,
VizPanel,
} from '@grafana/scenes';
import { t } from 'app/core/internationalization';
import kbn from 'app/core/utils/kbn';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
@ -8,6 +15,7 @@ import { getDefaultVizPanel } from '../../utils/utils';
import { AutoGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { LayoutRestorer } from '../layouts-shared/LayoutRestorer';
import { BulkActionElement } from '../types/BulkActionElement';
import { DashboardDropTarget } from '../types/DashboardDropTarget';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { EditableDashboardElement, EditableDashboardElementInfo } from '../types/EditableDashboardElement';
import { LayoutParent } from '../types/LayoutParent';
@ -24,12 +32,13 @@ export interface RowItemState extends SceneObjectState {
collapse?: boolean;
hideHeader?: boolean;
fillScreen?: boolean;
isDropTarget?: boolean;
conditionalRendering?: ConditionalRendering;
}
export class RowItem
extends SceneObjectBase<RowItemState>
implements LayoutParent, BulkActionElement, EditableDashboardElement
implements LayoutParent, BulkActionElement, EditableDashboardElement, DashboardDropTarget
{
public static Component = RowItemRenderer;
@ -38,6 +47,7 @@ export class RowItem
});
public readonly isEditableDashboardElement = true;
public readonly isDashboardDropTarget = true;
private _layoutRestorer = new LayoutRestorer();
public constructor(state?: Partial<RowItemState>) {
@ -129,6 +139,23 @@ export class RowItem
return this._getParentLayout().isLastRow(this);
}
public setIsDropTarget(isDropTarget: boolean) {
if (!!this.state.isDropTarget !== isDropTarget) {
this.setState({ isDropTarget });
}
}
public draggedPanelOutside(panel: VizPanel) {
this.getLayout().removePanel?.(panel);
this.setIsDropTarget(false);
}
public draggedPanelInside(panel: VizPanel) {
panel.clearParent();
this.getLayout().addPanel(panel);
this.setIsDropTarget(false);
}
public getRepeatVariable(): string | undefined {
return this._getRepeatBehavior()?.state.variableName;
}

@ -19,7 +19,7 @@ import { RowItem } from './RowItem';
import { RowItemMenu } from './RowItemMenu';
export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
const { layout, collapse: isCollapsed, fillScreen, hideHeader: isHeaderHidden } = model.useState();
const { layout, collapse: isCollapsed, fillScreen, hideHeader: isHeaderHidden, isDropTarget } = model.useState();
const isClone = useIsClone(model);
const { isEditing } = useDashboardState(model);
const isConditionallyHidden = useIsConditionallyHidden(model);
@ -42,6 +42,7 @@ export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
return (
<div
data-dashboard-drop-target-key={model.state.key}
className={cx(
styles.wrapper,
isEditing && !isCollapsed && styles.wrapperEditing,
@ -50,7 +51,8 @@ export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
shouldGrow && styles.wrapperGrow,
isConditionallyHidden && 'dashboard-visible-hidden-element',
!isClone && isSelected && 'dashboard-selected-element',
!isClone && !isSelected && selectableHighlight && 'dashboard-selectable-element'
!isClone && !isSelected && selectableHighlight && 'dashboard-selectable-element',
isDropTarget && 'dashboard-drop-target'
)}
onPointerDown={(e) => {
// If we selected and are clicking a button inside row header then don't de-select row

@ -1,4 +1,11 @@
import { SceneObjectState, SceneObjectBase, sceneGraph, VariableDependencyConfig, SceneObject } from '@grafana/scenes';
import {
SceneObjectState,
SceneObjectBase,
sceneGraph,
VariableDependencyConfig,
SceneObject,
VizPanel,
} from '@grafana/scenes';
import { t } from 'app/core/internationalization';
import kbn from 'app/core/utils/kbn';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
@ -7,6 +14,7 @@ import { getDefaultVizPanel } from '../../utils/utils';
import { AutoGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { LayoutRestorer } from '../layouts-shared/LayoutRestorer';
import { BulkActionElement } from '../types/BulkActionElement';
import { DashboardDropTarget } from '../types/DashboardDropTarget';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { EditableDashboardElement, EditableDashboardElementInfo } from '../types/EditableDashboardElement';
import { LayoutParent } from '../types/LayoutParent';
@ -19,11 +27,12 @@ import { TabsLayoutManager } from './TabsLayoutManager';
export interface TabItemState extends SceneObjectState {
layout: DashboardLayoutManager;
title?: string;
isDropTarget?: boolean;
}
export class TabItem
extends SceneObjectBase<TabItemState>
implements LayoutParent, BulkActionElement, EditableDashboardElement
implements LayoutParent, BulkActionElement, EditableDashboardElement, DashboardDropTarget
{
public static Component = TabItemRenderer;
@ -32,6 +41,8 @@ export class TabItem
});
public readonly isEditableDashboardElement = true;
public readonly isDashboardDropTarget = true;
private _layoutRestorer = new LayoutRestorer();
constructor(state?: Partial<TabItemState>) {
@ -115,6 +126,29 @@ export class TabItem
this.setState({ title });
}
public setIsDropTarget(isDropTarget: boolean) {
if (!!this.state.isDropTarget !== isDropTarget) {
this.setState({ isDropTarget });
}
}
public draggedPanelOutside(panel: VizPanel) {
this.getLayout().removePanel?.(panel);
this.setIsDropTarget(false);
}
public draggedPanelInside(panel: VizPanel) {
panel.clearParent();
this.getLayout().addPanel(panel);
this.setIsDropTarget(false);
const parentLayout = this.getParentLayout();
const tabIndex = parentLayout.state.tabs.findIndex((tab) => tab === this);
if (tabIndex !== parentLayout.state.currentTabIndex) {
parentLayout.setState({ currentTabIndex: tabIndex });
}
}
public getParentLayout(): TabsLayoutManager {
return this._getParentLayout();
}

@ -8,7 +8,7 @@ import { Tab, useElementSelection } from '@grafana/ui';
import { TabItem } from './TabItem';
export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
const { title, key } = model.useState();
const { title, key, isDropTarget } = model.useState();
const parentLayout = model.getParentLayout();
const { tabs, currentTabIndex } = parentLayout.useState();
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
@ -25,7 +25,8 @@ export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
truncate
className={cx(
isSelected && 'dashboard-selected-element',
isSelectable && !isSelected && 'dashboard-selectable-element'
isSelectable && !isSelected && 'dashboard-selectable-element',
isDropTarget && 'dashboard-drop-target'
)}
active={isActive}
role="presentation"
@ -34,6 +35,7 @@ export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
aria-selected={isActive}
onPointerDown={onSelect}
label={titleInterpolated}
data-dashboard-drop-target-key={model.state.key}
/>
);
}

@ -0,0 +1,12 @@
import { SceneObject, VizPanel } from '@grafana/scenes';
export interface DashboardDropTarget extends SceneObject {
isDashboardDropTarget: Readonly<true>;
setIsDropTarget?(isDropTarget: boolean): void;
draggedPanelOutside?(panel: VizPanel): void;
draggedPanelInside?(panel: VizPanel): void;
}
export function isDashboardDropTarget(scene: SceneObject): scene is DashboardDropTarget {
return 'isDashboardDropTarget' in scene && scene.isDashboardDropTarget === true;
}

@ -1,17 +1,5 @@
import { RefObject } from 'react';
import { SceneObject, VizPanel } from '@grafana/scenes';
import { SceneObject } from '@grafana/scenes';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { Point, Rect } from '../layout-manager/utils';
export interface IntermediateLayoutItem {
body: VizPanel;
origin: Point;
width: number;
height: number;
}
/**
* Abstraction to handle editing of different layout elements (wrappers for VizPanels and other objects)
* Also useful to when rendering / viewing an element outside it's layout scope
@ -22,11 +10,6 @@ export interface DashboardLayoutItem extends SceneObject {
*/
isDashboardLayoutItem: true;
/**
* Reference to the container DOM element.
*/
containerRef: RefObject<HTMLElement>;
/**
* Return layout item options (like repeat, repeat direction, etc. for the default DashboardGridItem)
*/
@ -41,21 +24,6 @@ export interface DashboardLayoutItem extends SceneObject {
* When coming out of panel edit
*/
editingCompleted?(withChanges: boolean): void;
/**
* Converts the current layout item into an intermediate format.
*/
toIntermediate(): IntermediateLayoutItem;
/**
* Calculates the distance from the current object to a specified point.
*/
distanceToPoint?(point: Point): number;
/**
* Retrieves the bounding box of an element or object.
*/
boundingBox?(): Rect | undefined;
}
export function isDashboardLayoutItem(obj: SceneObject): obj is DashboardLayoutItem {

@ -17,6 +17,7 @@ import { t } from 'app/core/internationalization';
import { initialIntervalVariableModelState } from 'app/features/variables/interval/reducer';
import { DashboardDatasourceBehaviour } from '../scene/DashboardDatasourceBehaviour';
import { DashboardLayoutOrchestrator } from '../scene/DashboardLayoutOrchestrator';
import { DashboardScene, DashboardSceneState } from '../scene/DashboardScene';
import { LibraryPanelBehavior } from '../scene/LibraryPanelBehavior';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
@ -477,3 +478,7 @@ export function useInterpolatedTitle<T extends SceneObjectState & { title?: stri
return sceneGraph.interpolate(scene, title, undefined, 'text');
}
export function getLayoutOrchestratorFor(scene: SceneObject): DashboardLayoutOrchestrator | undefined {
return getDashboardSceneFor(scene).state.layoutOrchestrator;
}

Loading…
Cancel
Save