mirror of https://github.com/grafana/grafana
Dynamic Dashboard: Drag and drop between grids, rows and tabs (#102714)
parent
b06556914c
commit
83bf06d435
@ -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; |
||||
} |
||||
} |
@ -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,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, |
||||
}), |
||||
}); |
||||
|
@ -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; |
||||
} |
Loading…
Reference in new issue