diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index 310ae7fe7b4..3346c110c51 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -143,6 +143,7 @@ export function PanelChrome({ onFocus, onMouseMove, onMouseEnter, + onDragStart, showMenuAlways = false, }: PanelChromeProps) { const theme = useTheme2(); @@ -150,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 pointerDownEvt = useRef(); const hasHeader = !hoverHeader; @@ -201,11 +202,13 @@ export function PanelChrome({ const onPointerUp = (evt: React.PointerEvent) => { evt.stopPropagation(); - const distance = Math.sqrt( - Math.pow(pointerDownPos.current.screenX - evt.screenX, 2) + - Math.pow(pointerDownPos.current.screenY - evt.screenY, 2) + const distance = Math.hypot( + pointerDownEvt.current?.screenX ?? 0 - evt.screenX, + pointerDownEvt.current?.screenY ?? 0 - evt.screenY ); + pointerDownEvt.current = undefined; + // If we are dragging some distance or clicking on elements that should cancel dragging (panel menu, etc) if ( distance > 10 || @@ -219,7 +222,7 @@ export function PanelChrome({ const onPointerDown = (evt: React.PointerEvent) => { evt.stopPropagation(); - pointerDownPos.current = { screenX: evt.screenX, screenY: evt.screenY }; + pointerDownEvt.current = evt; }; const headerContent = ( @@ -347,6 +350,11 @@ export function PanelChrome({ className={cx(styles.headerContainer, dragClass)} style={headerStyles} data-testid="header-container" + onPointerMove={() => { + if (pointerDownEvt.current) { + onDragStart?.(pointerDownEvt.current); + } + }} onPointerDown={onPointerDown} onMouseEnter={isSelectable ? onHeaderEnter : undefined} onMouseLeave={isSelectable ? onHeaderLeave : undefined} diff --git a/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts b/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts index 144dbfa3b79..c351009525f 100644 --- a/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts +++ b/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts @@ -19,6 +19,13 @@ export function getDashboardGridStyles(theme: GrafanaTheme2) { }, }, + '.dragging-active': { + '*': { + cursor: 'move', + userSelect: 'none', + }, + }, + [theme.breakpoints.down('md')]: { '.react-grid-layout': { height: 'unset !important', diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 7810e80b1e8..e7241022596 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -70,6 +70,8 @@ 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'; @@ -135,6 +137,8 @@ export interface DashboardSceneState extends SceneObjectState { /** options pane */ editPane: DashboardEditPane; scopesBridge: SceneScopesBridge | undefined; + /** Manages dragging/dropping of layout items */ + layoutOrchestrator: LayoutOrchestrator; } export class DashboardScene extends SceneObjectBase implements LayoutParent { @@ -188,6 +192,9 @@ 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 }), + }), }); this.serializer = diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx index f5082717e23..18c0ce91345 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx @@ -14,8 +14,19 @@ import { PanelSearchLayout } from './PanelSearchLayout'; import { DashboardAngularDeprecationBanner } from './angular/DashboardAngularDeprecationBanner'; export function DashboardSceneRenderer({ model }: SceneComponentProps) { - const { controls, overlay, editview, editPanel, viewPanelScene, panelSearch, panelsPerRow, isEditing, scopesBridge } = - model.useState(); + const { + controls, + overlay, + editview, + editPanel, + viewPanelScene, + panelSearch, + panelsPerRow, + isEditing, + scopesBridge, + layoutOrchestrator, + } = model.useState(); + const { placeholder } = layoutOrchestrator.useState(); const { type } = useParams(); const location = useLocation(); const navIndex = useSelector((state) => state.navIndex); @@ -62,18 +73,21 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps - {scopesBridge && } - {editPanel && } - {!editPanel && ( - } - body={renderBody()} - /> - )} - {overlay && } - + <> + {placeholder && } + + {scopesBridge && } + {editPanel && } + {!editPanel && ( + } + body={renderBody()} + /> + )} + {overlay && } + + ); } 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 077128a17e2..cb609b8e485 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DashboardGridItem.tsx @@ -1,4 +1,5 @@ import { isEqual } from 'lodash'; +import { createRef } from 'react'; import { Unsubscribable } from 'rxjs'; import { @@ -20,7 +21,8 @@ import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components import { getCloneKey } from '../../utils/clone'; import { getMultiVariableValues } from '../../utils/utils'; -import { DashboardLayoutItem } from '../types/DashboardLayoutItem'; +import { Point, Rect } from '../layout-manager/utils'; +import { DashboardLayoutItem, IntermediateLayoutItem } from '../types/DashboardLayoutItem'; import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent'; import { getDashboardGridItemOptions } from './DashboardGridItemEditor'; @@ -52,6 +54,8 @@ export class DashboardGridItem private _gridSizeSub: Unsubscribable | undefined; + public containerRef = createRef(); + public constructor(state: DashboardGridItemState) { super(state); @@ -245,4 +249,16 @@ 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 d9749c79f17..1f3216dea99 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -467,7 +467,7 @@ function DefaultGridLayoutManagerRenderer({ model }: SceneComponentProps diff --git a/public/app/features/dashboard-scene/scene/layout-manager/DropZonePlaceholder.tsx b/public/app/features/dashboard-scene/scene/layout-manager/DropZonePlaceholder.tsx new file mode 100644 index 00000000000..c47dd1275ec --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layout-manager/DropZonePlaceholder.tsx @@ -0,0 +1,46 @@ +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 new file mode 100644 index 00000000000..2a221189fee --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layout-manager/LayoutOrchestrator.tsx @@ -0,0 +1,139 @@ +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 }; + + /** 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; + } + + document.body.setPointerCapture(e.pointerId); + const targetRect = e.target.getBoundingClientRect(); + this.dragOffset = { top: e.y - targetRect.top, left: e.x - targetRect.left }; + this.setState({ activeLayoutItemRef: closestLayoutItem.getRef() }); + document.addEventListener('pointermove', this.onDrag); + document.addEventListener('pointerup', this.onDragEnd); + document.body.classList.add('dragging-active'); + }; + + /** The drop zone closest to the current mouse position while dragging. */ + public activeDropZone: (DropZone & { layout: SceneObjectRef }) | undefined; + + /** 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 }; + + layoutItemContainer.style.setProperty('--x-pos', `${cursorPos.x}px`); + layoutItemContainer.style.setProperty('--y-pos', `${cursorPos.y}px`); + 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, + }); + this.state.placeholder?.setState({ + top: 0, + left: 0, + width: 0, + height: 0, + }); + 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) { + const sceneLayouts = sceneGraph + .findAllObjects(this.getRoot(), isSceneLayoutWithDragAndDrop) + .filter(isSceneLayoutWithDragAndDrop); + let closestDropZone: (DropZone & { layout: SceneObjectRef }) | undefined = undefined; + let closestDistance = Number.MAX_VALUE; + for (const layout of sceneLayouts) { + const curClosestDropZone = layout.closestDropZone(p); + if (curClosestDropZone.distanceToPoint < closestDistance) { + closestDropZone = { ...curClosestDropZone, layout: layout.getRef() }; + closestDistance = curClosestDropZone.distanceToPoint; + } + } + + return closestDropZone; + } +} + +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 new file mode 100644 index 00000000000..e9e9ef1b3c4 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layout-manager/utils.ts @@ -0,0 +1,63 @@ +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 29997c4c2ed..61f92a6ea69 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,24 +1,26 @@ import { isEqual } from 'lodash'; +import { createRef } from 'react'; import { - SceneObjectState, - VizPanel, - SceneObjectBase, - sceneGraph, CustomVariable, - MultiValueVariable, - VariableValueSingle, - VizPanelState, - SceneVariableSet, LocalValueVariable, + MultiValueVariable, + sceneGraph, + SceneObjectBase, + SceneObjectState, + SceneVariableSet, VariableDependencyConfig, + VariableValueSingle, + VizPanel, + VizPanelState, } from '@grafana/scenes'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { ConditionalRendering } from '../../conditional-rendering/ConditionalRendering'; import { getCloneKey } from '../../utils/clone'; import { getMultiVariableValues } from '../../utils/utils'; -import { DashboardLayoutItem } from '../types/DashboardLayoutItem'; +import { Point, Rect } from '../layout-manager/utils'; +import { DashboardLayoutItem, IntermediateLayoutItem } from '../types/DashboardLayoutItem'; import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent'; import { getOptions } from './ResponsiveGridItemEditor'; @@ -29,6 +31,7 @@ export interface ResponsiveGridItemState extends SceneObjectState { hideWhenNoData?: boolean; repeatedPanels?: VizPanel[]; variableName?: string; + isHidden?: boolean; conditionalRendering?: ConditionalRendering; } @@ -40,6 +43,8 @@ export class ResponsiveGridItem extends SceneObjectBase onVariableUpdateCompleted: () => this.performRepeat(), }); public readonly isDashboardLayoutItem = true; + public containerRef = createRef(); + public cachedBoundingBox: Rect | undefined; public constructor(state: ResponsiveGridItemState) { super({ ...state, conditionalRendering: state?.conditionalRendering ?? ConditionalRendering.createEmpty() }); @@ -64,10 +69,6 @@ export class ResponsiveGridItem extends SceneObjectBase return getOptions(this); } - public toggleHideWhenNoData() { - this.setState({ hideWhenNoData: !this.state.hideWhenNoData }); - } - public performRepeat() { if (!this.state.variableName || sceneGraph.hasVariableDependencyInLoadingState(this)) { return; @@ -141,4 +142,78 @@ export class ResponsiveGridItem extends SceneObjectBase this.setState(stateUpdate); 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.'); + } + + this.cachedBoundingBox = itemContainer.getBoundingClientRect(); + return this.cachedBoundingBox; + } + + 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; + } + + 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(); + + return { + body: this.state.body, + origin: { + x: rect.left, + y: rect.top, + }, + width: rect.width, + height: rect.height, + }; + } +} + +// 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 15570072389..2e36f85e108 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,15 +1,15 @@ -import { css, cx } from '@emotion/css'; +import { cx } from '@emotion/css'; import { SceneComponentProps } from '@grafana/scenes'; -import { useStyles2 } from '@grafana/ui'; import { useDashboardState, useIsConditionallyHidden } from '../../utils/utils'; import { ResponsiveGridItem } from './ResponsiveGridItem'; -export function ResponsiveGridItemRenderer({ model }: SceneComponentProps) { +export interface ResponsiveGridItemProps extends SceneComponentProps {} + +export function ResponsiveGridItemRenderer({ model }: ResponsiveGridItemProps) { const { body } = model.useState(); - const style = useStyles2(getStyles); const { showHiddenElements } = useDashboardState(model); const isConditionallyHidden = useIsConditionallyHidden(model); @@ -21,27 +21,14 @@ export function ResponsiveGridItemRenderer({ model }: SceneComponentProps {model.state.repeatedPanels.map((item) => ( -
+
))} ) : ( -
+
); } - -function getStyles() { - return { - wrapper: css({ - width: '100%', - height: '100%', - position: 'relative', - }), - }; -} 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 new file mode 100644 index 00000000000..3c705b34540 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayout.tsx @@ -0,0 +1,207 @@ +import { createRef, CSSProperties, PointerEvent } from 'react'; + +import { 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 { ResponsiveGridItem } from './ResponsiveGridItem'; +import { ResponsiveGridLayoutRenderer } from './ResponsiveGridLayoutRenderer'; + +export interface ResponsiveGridLayoutState extends SceneObjectState, ResponsiveGridLayoutOptions { + children: ResponsiveGridItem[]; + + /** + * True when the item should be rendered but not visible. + * Useful for conditional display of layout items + */ + isHidden?: boolean; + + /** + * For media query for screens smaller than md breakpoint + */ + md?: ResponsiveGridLayoutOptions; + + /** True when the items should be lazy loaded */ + isLazy?: boolean; +} + +export interface ResponsiveGridLayoutOptions { + /** + * Useful for setting a height on items without specifying how many rows there will be. + * Defaults to 320px + */ + autoRows?: CSSProperties['gridAutoRows']; + /** + * This overrides the autoRows with a specific row template. + */ + templateRows?: CSSProperties['gridTemplateRows']; + /** + * Defaults to repeat(auto-fit, minmax(400px, 1fr)). This pattern us useful for equally sized items with a min width of 400px + * and dynamic max width split equally among columns. + */ + templateColumns: CSSProperties['gridTemplateColumns']; + /** In Grafana design system grid units (8px) */ + rowGap: number; + /** In Grafana design system grid units (8px) */ + columnGap: number; + justifyItems?: CSSProperties['justifyItems']; + alignItems?: CSSProperties['alignItems']; + justifyContent?: CSSProperties['justifyContent']; +} + +export class ResponsiveGridLayout + extends SceneObjectBase + implements SceneLayoutWithDragAndDrop +{ + public layoutOrchestrator: LayoutOrchestrator | undefined; + + public static Component = ResponsiveGridLayoutRenderer; + + 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; + + public constructor(state: Partial) { + super({ + rowGap: 1, + columnGap: 1, + templateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', + autoRows: state.autoRows ?? `320px`, + children: state.children ?? [], + ...state, + }); + + this.addActivationHandler(this.activationHandler); + } + + private activationHandler = () => { + this.layoutOrchestrator = getDashboardSceneFor(this).state.layoutOrchestrator; + }; + + public isDraggable(): boolean { + return true; + } + + public getDragClass(): string { + return `grid-drag-handle-${this.state.key}`; + } + + public getDragClassCancel(): string { + return 'grid-drag-cancel'; + } + + public getDragHooks = () => { + return { onDragStart: this.onPointerDown }; + }; + + public onPointerDown = (e: PointerEvent, panel: VizPanel) => { + const cannotDrag = this.cannotDrag(e.target); + if (cannotDrag || !this.layoutOrchestrator) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + // Refresh bounding boxes for all responsive grid items + for (const child of this.state.children) { + child.computeBoundingBox(); + } + + this.scrollPos = closestScroll(this.containerRef.current); + this.layoutOrchestrator.onDragStart(e.nativeEvent, panel); + }; + + private cannotDrag(el: EventTarget): boolean | Element { + const dragClass = this.getDragClass(); + const dragCancelClass = this.getDragClassCancel(); + + // 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}`)); + } + + /** + * 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 }; + } + }); + + this.activeIndex = closestIndex; + this.activeGridCell = closest; + + return { ...closestRect, distanceToPoint: minDistance }; + } + + public importLayoutItem(layoutItem: DashboardLayoutItem) { + const layoutItemIR = layoutItem.toIntermediate(); + const layoutChildren = [...this.state.children]; + + layoutItemIR.body.clearParent(); + + const newLayoutItem = new ResponsiveGridItem({ body: layoutItemIR.body }); + layoutChildren.splice(this.activeIndex ?? 0, 0, newLayoutItem); + + this.setState({ + children: layoutChildren, + }); + + newLayoutItem.activate(); + } + + public removeLayoutItem(layoutItem: DashboardLayoutItem) { + this.setState({ + children: this.state.children.filter((c) => c !== layoutItem), + }); + + layoutItem.clearParent(); + } +} + +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; +} diff --git a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.test.ts b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.test.ts index ff43c9b1e8f..60be3c0959e 100644 --- a/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.test.ts +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutManager.test.ts @@ -1,9 +1,10 @@ -import { SceneCSSGridLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes'; +import { SceneQueryRunner, VizPanel } from '@grafana/scenes'; import { findVizPanelByKey } from '../../utils/utils'; import { DashboardScene } from '../DashboardScene'; import { ResponsiveGridItem } from './ResponsiveGridItem'; +import { ResponsiveGridLayout } from './ResponsiveGridLayout'; import { ResponsiveGridLayoutManager } from './ResponsiveGridLayoutManager'; describe('ResponsiveGridLayoutManager', () => { @@ -45,7 +46,7 @@ function setup() { }), ]; - const manager = new ResponsiveGridLayoutManager({ layout: new SceneCSSGridLayout({ children: gridItems }) }); + const manager = new ResponsiveGridLayoutManager({ layout: new ResponsiveGridLayout({ children: gridItems }) }); new DashboardScene({ body: manager }); 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 1f3605a2031..8130cecec00 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 @@ -1,4 +1,4 @@ -import { SceneComponentProps, SceneCSSGridLayout, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; +import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; import { t } from 'app/core/internationalization'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; @@ -10,10 +10,11 @@ import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; import { ResponsiveGridItem } from './ResponsiveGridItem'; +import { ResponsiveGridLayout } from './ResponsiveGridLayout'; import { getEditOptions } from './ResponsiveGridLayoutManagerEditor'; interface ResponsiveGridLayoutManagerState extends SceneObjectState { - layout: SceneCSSGridLayout; + layout: ResponsiveGridLayout; } export class ResponsiveGridLayoutManager @@ -149,7 +150,7 @@ export class ResponsiveGridLayoutManager public static createEmpty(): ResponsiveGridLayoutManager { return new ResponsiveGridLayoutManager({ - layout: new SceneCSSGridLayout({ + layout: new ResponsiveGridLayout({ children: [], templateColumns: ResponsiveGridLayoutManager.defaultCSS.templateColumns, autoRows: ResponsiveGridLayoutManager.defaultCSS.autoRows, 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 new file mode 100644 index 00000000000..2139900b4c4 --- /dev/null +++ b/public/app/features/dashboard-scene/scene/layout-responsive-grid/ResponsiveGridLayoutRenderer.tsx @@ -0,0 +1,118 @@ +import { css, cx } from '@emotion/css'; +import { useEffect } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { LazyLoader, SceneComponentProps } from '@grafana/scenes'; +import { useStyles2 } from '@grafana/ui'; + +import { getDashboardSceneFor } from '../../utils/utils'; + +import { ResponsiveGridLayout, ResponsiveGridLayoutState } from './ResponsiveGridLayout'; + +export function ResponsiveGridLayoutRenderer({ model }: SceneComponentProps) { + const { children, isHidden, isLazy } = model.useState(); + 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 ( +
+
+ {children.map((item) => { + const Wrapper = isLazy ? LazyLoader : 'div'; + const isDragging = activeLayoutItem === item; + + return ( + + + + ); + })} +
+ ); +} + +const getStyles = (theme: GrafanaTheme2, state: ResponsiveGridLayoutState) => ({ + container: css({ + display: 'grid', + position: 'relative', + gridTemplateColumns: state.templateColumns, + gridTemplateRows: state.templateRows || 'unset', + gridAutoRows: state.autoRows || 'unset', + rowGap: theme.spacing(state.rowGap ?? 1), + columnGap: theme.spacing(state.columnGap ?? 1), + justifyItems: state.justifyItems || 'unset', + alignItems: state.alignItems || 'unset', + justifyContent: state.justifyContent || 'unset', + flexGrow: 1, + + [theme.breakpoints.down('md')]: state.md + ? { + gridTemplateRows: state.md.templateRows, + gridTemplateColumns: state.md.templateColumns, + rowGap: state.md.rowGap ? theme.spacing(state.md.rowGap ?? 1) : undefined, + columnGap: state.md.columnGap ? theme.spacing(state.md.rowGap ?? 1) : undefined, + justifyItems: state.md.justifyItems, + alignItems: state.md.alignItems, + justifyContent: state.md.justifyContent, + } + : undefined, + }), + wrapper: css({ + display: 'grid', + position: 'relative', + width: '100%', + height: '100%', + overflow: 'hidden', + }), + dragging: css({ + position: 'fixed', + top: 0, + left: 0, + zIndex: theme.zIndex.portal + 1, + pointerEvents: 'none', + }), +}); diff --git a/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts b/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts index fef58b0d071..48301007b5d 100644 --- a/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts +++ b/public/app/features/dashboard-scene/scene/types/DashboardLayoutItem.ts @@ -1,6 +1,17 @@ -import { SceneObject } from '@grafana/scenes'; +import { RefObject } from 'react'; + +import { SceneObject, VizPanel } 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 @@ -11,6 +22,11 @@ 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) */ @@ -25,6 +41,21 @@ 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/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts index 9faf62a1c9a..72a04194f82 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/ResponsiveGridLayoutSerializer.ts @@ -1,7 +1,7 @@ -import { SceneCSSGridLayout } from '@grafana/scenes'; import { DashboardV2Spec, ResponsiveGridLayoutItemKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import { ResponsiveGridItem } from '../../scene/layout-responsive-grid/ResponsiveGridItem'; +import { ResponsiveGridLayout } from '../../scene/layout-responsive-grid/ResponsiveGridLayout'; import { ResponsiveGridLayoutManager } from '../../scene/layout-responsive-grid/ResponsiveGridLayoutManager'; import { DashboardLayoutManager, LayoutManagerSerializer } from '../../scene/types/DashboardLayoutManager'; import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph'; @@ -73,7 +73,7 @@ export class ResponsiveGridLayoutSerializer implements LayoutManagerSerializer { }); return new ResponsiveGridLayoutManager({ - layout: new SceneCSSGridLayout({ + layout: new ResponsiveGridLayout({ templateColumns: layout.spec.col, autoRows: layout.spec.row, children, diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts index cd483cafa0c..4d5429e1b34 100644 --- a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts +++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.test.ts @@ -1,7 +1,8 @@ -import { SceneCSSGridLayout, SceneGridLayout } from '@grafana/scenes'; +import { SceneGridLayout } from '@grafana/scenes'; import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager'; +import { ResponsiveGridLayout } from '../../scene/layout-responsive-grid/ResponsiveGridLayout'; import { ResponsiveGridLayoutManager } from '../../scene/layout-responsive-grid/ResponsiveGridLayoutManager'; import { RowItem } from '../../scene/layout-rows/RowItem'; import { RowItemRepeaterBehavior } from '../../scene/layout-rows/RowItemRepeaterBehavior'; @@ -232,7 +233,7 @@ describe('serialization', () => { title: 'Row 1', isCollapsed: false, layout: new ResponsiveGridLayoutManager({ - layout: new SceneCSSGridLayout({ + layout: new ResponsiveGridLayout({ children: [], templateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', autoRows: 'minmax(min-content, max-content)', diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts index 7e438b72b56..db16a475798 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts @@ -9,7 +9,6 @@ import { GroupByVariable, IntervalVariable, QueryVariable, - SceneCSSGridLayout, SceneGridLayout, SceneGridRow, SceneRefreshPicker, @@ -41,6 +40,7 @@ import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior'; import { ResponsiveGridItem } from '../scene/layout-responsive-grid/ResponsiveGridItem'; +import { ResponsiveGridLayout } from '../scene/layout-responsive-grid/ResponsiveGridLayout'; import { ResponsiveGridLayoutManager } from '../scene/layout-responsive-grid/ResponsiveGridLayoutManager'; import { RowItem } from '../scene/layout-rows/RowItem'; import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager'; @@ -475,7 +475,7 @@ describe('dynamic layouts', () => { rows: [ new RowItem({ layout: new ResponsiveGridLayoutManager({ - layout: new SceneCSSGridLayout({ + layout: new ResponsiveGridLayout({ children: [ new ResponsiveGridItem({ body: new VizPanel({}), @@ -520,7 +520,7 @@ describe('dynamic layouts', () => { const scene = setupDashboardScene( getMinimalSceneState( new ResponsiveGridLayoutManager({ - layout: new SceneCSSGridLayout({ + layout: new ResponsiveGridLayout({ autoRows: 'rowString', templateColumns: 'colString', children: [