diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index 7b534d88b09..342e2eeb638 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -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 + ); - const onPointerUp = (evt: React.PointerEvent) => { - evt.stopPropagation(); + if ( + distance > 10 || + (dragClassCancel && evt.target instanceof Element && evt.target.closest(`.${dragClassCancel}`)) + ) { + return; + } - const distance = Math.hypot( - pointerDownPos.current.screenX - evt.screenX, - pointerDownPos.current.screenY - evt.screenY - ); + // 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] + ); - pointerDownPos.current = { screenX: 0, screenY: 0 }; + const onPointerDown = React.useCallback( + (evt: React.PointerEvent) => { + evt.stopPropagation(); - // 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; - } + pointerDownLocation.current = { x: evt.clientX, y: evt.clientY }; - onSelect?.(evt); - }; - - const onPointerDown = (evt: React.PointerEvent) => { - evt.stopPropagation(); - - pointerDownPos.current = { screenX: evt.screenX, screenY: evt.screenY }; - - onDragStart?.(evt); - }; + onDragStart?.(evt); + }, + [onDragStart] + ); const headerContent = ( <> diff --git a/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts b/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts index aa243c17c68..dbd5f5726eb 100644 --- a/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts +++ b/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts @@ -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, + }, }); } diff --git a/public/app/features/dashboard-scene/scene/DashboardLayoutOrchestrator.tsx b/public/app/features/dashboard-scene/scene/DashboardLayoutOrchestrator.tsx new file mode 100644 index 00000000000..0ca38a645bb --- /dev/null +++ b/public/app/features/dashboard-scene/scene/DashboardLayoutOrchestrator.tsx @@ -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; +} + +export class DashboardLayoutOrchestrator extends SceneObjectBase { + 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; + } +} diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index d879f1d75c8..023e026ffe8 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -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 implements LayoutParent { @@ -190,9 +189,7 @@ export class DashboardScene extends SceneObjectBase 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 = diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx index 18c0ce91345..22f3fd9eab7 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx @@ -26,7 +26,6 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps state.navIndex); @@ -74,7 +73,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps - {placeholder && } + {layoutOrchestrator && } {scopesBridge && } {editPanel && } diff --git a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx index 1f944a71aa5..6b6639efd8b 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx @@ -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(); - 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.'); - } } diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index 3628ea6f026..ad90fe94f51 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -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) { diff --git a/public/app/features/dashboard-scene/scene/layout-manager/DropZonePlaceholder.tsx b/public/app/features/dashboard-scene/scene/layout-manager/DropZonePlaceholder.tsx deleted file mode 100644 index c47dd1275ec..00000000000 --- a/public/app/features/dashboard-scene/scene/layout-manager/DropZonePlaceholder.tsx +++ /dev/null @@ -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 { - static Component = ({ model }: SceneComponentProps) => { - const { width, height, left, top } = model.useState(); - const styles = useStyles2(getStyles); - - return ( - -
0 && height > 0, - })} - style={{ width, height, transform: `translate(${left}px, ${top}px)` }} - /> - - ); - }; -} - -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', - }), -}); diff --git a/public/app/features/dashboard-scene/scene/layout-manager/LayoutOrchestrator.tsx b/public/app/features/dashboard-scene/scene/layout-manager/LayoutOrchestrator.tsx deleted file mode 100644 index 9789f85d005..00000000000 --- a/public/app/features/dashboard-scene/scene/layout-manager/LayoutOrchestrator.tsx +++ /dev/null @@ -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; - placeholder?: DropZonePlaceholder; -} - -export class LayoutOrchestrator extends SceneObjectBase { - /** 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 }) | 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 = { 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 }) | 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 = ['top', 'left', 'bottom', 'right']; - return a && b && dims.every((dim) => b[dim] === a[dim]); -} diff --git a/public/app/features/dashboard-scene/scene/layout-manager/utils.ts b/public/app/features/dashboard-scene/scene/layout-manager/utils.ts deleted file mode 100644 index e9e9ef1b3c4..00000000000 --- a/public/app/features/dashboard-scene/scene/layout-manager/utils.ts +++ /dev/null @@ -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(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( - sceneObject: SceneObject, - objectIsOfType: (s: SceneObject) => s is T -): T | undefined { - let curSceneObject: SceneObject | undefined = sceneObject; - - while (curSceneObject && !objectIsOfType(curSceneObject)) { - curSceneObject = curSceneObject.parent; - } - - return curSceneObject; -} diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx index 17ebfce3ef1..af6e1b78687 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItem.tsx @@ -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 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(); - 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 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); -} diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemRenderer.tsx index 2053950d221..3e21119dc45 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridItemRenderer.tsx @@ -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 {} - -export function AutoGridItemRenderer({ model }: AutoGridItemProps) { - const { body } = model.useState(); +export function AutoGridItemRenderer({ model }: SceneComponentProps) { + 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) => ( -
+ {repeatedPanels.map((item) => ( +
))} ) : ( -
- +
model.setRef(ref)} data-auto-grid-item-drop-target={isDragging ? key : undefined}> + {isDragged &&
} + +
+ +
); } + +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, + }), +}); diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayout.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayout.tsx index 1af5926faf8..5d370d2a884 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayout.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayout.tsx @@ -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 implements SceneLayoutWithDragAndDrop { - public layoutOrchestrator: LayoutOrchestrator | undefined; - +export class AutoGridLayout extends SceneObjectBase implements SceneLayout { public static Component = AutoGridLayoutRenderer; - public containerRef = createRef(); - - public activeIndex: number | undefined; - public activeGridCell = { row: 1, column: 1 }; - public columnCount = 1; - // maybe not needed? - public rowCount = 1; - public scrollPos: ReturnType | 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) { super({ @@ -79,12 +77,21 @@ export class AutoGridLayout extends SceneObjectBase 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 impleme return 'grid-drag-cancel'; } - public getDragHooks = () => { - return { onDragStart: this.onPointerDown }; - }; + public getDragHooks() { + return { + onDragStart: this._onDragStart, + }; + } - public onPointerDown = (e: PointerEvent, panel: VizPanel) => { - const cannotDrag = this.cannotDrag(e.target); - if (cannotDrag || !this.layoutOrchestrator) { + 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()}`); + } + + // 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 }; + // Stop inside dragging + private _onDragEnd() { + window.getSelection()?.removeAllRanges(); - 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 }; - } - }); + 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; + } + + children.splice(draggedIdx, 1); + children.splice(draggedOverIdx, 0, this._draggedGridItem!); + + this.setState({ children }); + } + + private _updatePanelPosition(top: number, left: number) { + this._containerRef?.style.setProperty(DRAGGED_ITEM_TOP, `${top}px`); + this._containerRef?.style.setProperty(DRAGGED_ITEM_LEFT, `${left}px`); + } + + 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`); + } + + 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); } } -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 }; - } - - return el ? closestScroll(el.parentElement) : { scrollTop: 0, scrollTopMax: 0, wrapper: el }; -} - -function canScroll(el: HTMLElement) { - const oldScroll = el.scrollTop; - el.scrollTop = Number.MAX_SAFE_INTEGER; - const newScroll = el.scrollTop; - el.scrollTop = oldScroll; - - return newScroll > 0; -} +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'; diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx index 95c968369a7..14a6cfb6b7b 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.tsx @@ -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; } diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutRenderer.tsx index 190bf1daf45..e90c3d40272 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutRenderer.tsx @@ -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 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 ( -
-
- {children.map((item) => { - const Wrapper = isLazy ? LazyLoader : 'div'; - const isDragging = activeLayoutItem === item; - - return ( - - - - ); - })} +
model.setRef(ref)}> + {children.map((item) => + isLazy ? ( + + + + ) : ( + + ) + )}
); } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx index 6f180efe87a..e31caf6798e 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx @@ -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 - 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) { @@ -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; } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx index 7ddd553d072..d7544d778b6 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemRenderer.tsx @@ -19,7 +19,7 @@ import { RowItem } from './RowItem'; import { RowItemMenu } from './RowItemMenu'; export function RowItemRenderer({ model }: SceneComponentProps) { - 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) { return (
) { 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 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 32509869a96..d92461f43af 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx @@ -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 - 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) { @@ -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(); } diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx index f0b5c1080ed..2b43c7908f7 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx @@ -8,7 +8,7 @@ import { Tab, useElementSelection } from '@grafana/ui'; import { TabItem } from './TabItem'; export function TabItemRenderer({ model }: SceneComponentProps) { - 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) { 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) { aria-selected={isActive} onPointerDown={onSelect} label={titleInterpolated} + data-dashboard-drop-target-key={model.state.key} /> ); } diff --git a/public/app/features/dashboard-scene/scene/types/DashboardDropTarget.ts b/public/app/features/dashboard-scene/scene/types/DashboardDropTarget.ts new file mode 100644 index 00000000000..de588c5efbd --- /dev/null +++ b/public/app/features/dashboard-scene/scene/types/DashboardDropTarget.ts @@ -0,0 +1,12 @@ +import { SceneObject, VizPanel } from '@grafana/scenes'; + +export interface DashboardDropTarget extends SceneObject { + isDashboardDropTarget: Readonly; + 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; +} diff --git a/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts b/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts index 15253215329..21a4fc6e7eb 100644 --- a/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts +++ b/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts @@ -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; - /** * 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 { diff --git a/public/app/features/dashboard-scene/utils/utils.ts b/public/app/features/dashboard-scene/utils/utils.ts index dd09e70b0fb..c51dd9765cf 100644 --- a/public/app/features/dashboard-scene/utils/utils.ts +++ b/public/app/features/dashboard-scene/utils/utils.ts @@ -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