Scenes: Implement drag and drop support for SceneCSSGridLayout (#99386)

* Draft: Move css grid stuff to main

* Scenes: Implement drag and drop support for SceneCSSGridLayout

* Fix some nits

* WIP Refactor

* Added a comment

* Add orchestrator to v2schema and fix error (#100964)

* Add orchestrator to v2schema and fix error

* Display placeholder directly when starting to drag

---------

Co-authored-by: kay delaney <kay@grafana.com>

* Fix merge issue

* Fix panel drag offset and remove console.logs

* Fix small nit

* Fix issue where layout options weren't refreshed on changing layout

* Return empty array from useEditPaneOptions if dashboard body isn't LayoutOrchestrator

* Expect layoutOrchestrator when serializing scene

* Fix tests to expect orchestrator instead of layoutManager

* Fix tests in transformSaveModelSchemaV2ToScene.test.ts

* Fix tests in transformSceneToSaveModelSchemaV2.test.ts

* More test fixes

* fix lint issues

* Small fixes

* default to adding layout orchestrator?

* Empty commit

* delete artifactspage.go

* remove artifactspage.tmpl.html

* betterer

* WIP refactor, not ready for review

* Slightly fix placeholder behavior. still broken though

* Fixed some visual glitches. Still very buggy

* Fix layout bugginess when initiating dragging

* more WIP

* Fix some broken logic

* clean up

* Move LayoutOrchestrator to dashboard state

* More cleanup

* Fix misaligned placeholders after changing layout options or resizing browser

* Fix issue with dragging vs selection

* Fix scroll position jumping when dragging in vertically-oriented grid

* Fix import order errors

* Remove '!' from layoutOrchestrator references

* Add LazyLoader support

* Dynamic Dashboards: Responsive Grid drag and drop minor fixes (#102430)

Changes

---------

Co-authored-by: Oscar Kilhed <oscar.kilhed@grafana.com>
Co-authored-by: Bogdan Matei <bogdan.matei@grafana.com>
This commit is contained in:
kay delaney
2025-03-19 11:53:58 +00:00
committed by GitHub
parent 0c58d39e76
commit 4eab2ea9d3
19 changed files with 788 additions and 67 deletions

View File

@ -143,6 +143,7 @@ export function PanelChrome({
onFocus, onFocus,
onMouseMove, onMouseMove,
onMouseEnter, onMouseEnter,
onDragStart,
showMenuAlways = false, showMenuAlways = false,
}: PanelChromeProps) { }: PanelChromeProps) {
const theme = useTheme2(); const theme = useTheme2();
@ -150,7 +151,7 @@ export function PanelChrome({
const panelContentId = useId(); const panelContentId = useId();
const panelTitleId = useId().replace(/:/g, '_'); const panelTitleId = useId().replace(/:/g, '_');
const { isSelected, onSelect, isSelectable } = useElementSelection(selectionId); const { isSelected, onSelect, isSelectable } = useElementSelection(selectionId);
const pointerDownPos = useRef<{ screenX: number; screenY: number }>({ screenX: 0, screenY: 0 }); const pointerDownEvt = useRef<React.PointerEvent | undefined>();
const hasHeader = !hoverHeader; const hasHeader = !hoverHeader;
@ -201,11 +202,13 @@ export function PanelChrome({
const onPointerUp = (evt: React.PointerEvent) => { const onPointerUp = (evt: React.PointerEvent) => {
evt.stopPropagation(); evt.stopPropagation();
const distance = Math.sqrt( const distance = Math.hypot(
Math.pow(pointerDownPos.current.screenX - evt.screenX, 2) + pointerDownEvt.current?.screenX ?? 0 - evt.screenX,
Math.pow(pointerDownPos.current.screenY - evt.screenY, 2) 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 we are dragging some distance or clicking on elements that should cancel dragging (panel menu, etc)
if ( if (
distance > 10 || distance > 10 ||
@ -219,7 +222,7 @@ export function PanelChrome({
const onPointerDown = (evt: React.PointerEvent) => { const onPointerDown = (evt: React.PointerEvent) => {
evt.stopPropagation(); evt.stopPropagation();
pointerDownPos.current = { screenX: evt.screenX, screenY: evt.screenY }; pointerDownEvt.current = evt;
}; };
const headerContent = ( const headerContent = (
@ -347,6 +350,11 @@ export function PanelChrome({
className={cx(styles.headerContainer, dragClass)} className={cx(styles.headerContainer, dragClass)}
style={headerStyles} style={headerStyles}
data-testid="header-container" data-testid="header-container"
onPointerMove={() => {
if (pointerDownEvt.current) {
onDragStart?.(pointerDownEvt.current);
}
}}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onMouseEnter={isSelectable ? onHeaderEnter : undefined} onMouseEnter={isSelectable ? onHeaderEnter : undefined}
onMouseLeave={isSelectable ? onHeaderLeave : undefined} onMouseLeave={isSelectable ? onHeaderLeave : undefined}

View File

@ -19,6 +19,13 @@ export function getDashboardGridStyles(theme: GrafanaTheme2) {
}, },
}, },
'.dragging-active': {
'*': {
cursor: 'move',
userSelect: 'none',
},
},
[theme.breakpoints.down('md')]: { [theme.breakpoints.down('md')]: {
'.react-grid-layout': { '.react-grid-layout': {
height: 'unset !important', height: 'unset !important',

View File

@ -70,6 +70,8 @@ import { isUsingAngularDatasourcePlugin, isUsingAngularPanelPlugin } from './ang
import { setupKeyboardShortcuts } from './keyboardShortcuts'; import { setupKeyboardShortcuts } from './keyboardShortcuts';
import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DashboardGridItem } from './layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; 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 { LayoutRestorer } from './layouts-shared/LayoutRestorer';
import { addNewRowTo, addNewTabTo } from './layouts-shared/addNew'; import { addNewRowTo, addNewTabTo } from './layouts-shared/addNew';
import { DashboardLayoutManager } from './types/DashboardLayoutManager'; import { DashboardLayoutManager } from './types/DashboardLayoutManager';
@ -135,6 +137,8 @@ export interface DashboardSceneState extends SceneObjectState {
/** options pane */ /** options pane */
editPane: DashboardEditPane; editPane: DashboardEditPane;
scopesBridge: SceneScopesBridge | undefined; scopesBridge: SceneScopesBridge | undefined;
/** Manages dragging/dropping of layout items */
layoutOrchestrator: LayoutOrchestrator;
} }
export class DashboardScene extends SceneObjectBase<DashboardSceneState> implements LayoutParent { export class DashboardScene extends SceneObjectBase<DashboardSceneState> implements LayoutParent {
@ -188,6 +192,9 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
...state, ...state,
editPane: new DashboardEditPane(), editPane: new DashboardEditPane(),
scopesBridge: config.featureToggles.scopeFilters ? new SceneScopesBridge({}) : undefined, scopesBridge: config.featureToggles.scopeFilters ? new SceneScopesBridge({}) : undefined,
layoutOrchestrator: new LayoutOrchestrator({
placeholder: new DropZonePlaceholder({ top: 0, left: 0, width: 0, height: 0 }),
}),
}); });
this.serializer = this.serializer =

View File

@ -14,8 +14,19 @@ import { PanelSearchLayout } from './PanelSearchLayout';
import { DashboardAngularDeprecationBanner } from './angular/DashboardAngularDeprecationBanner'; import { DashboardAngularDeprecationBanner } from './angular/DashboardAngularDeprecationBanner';
export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) { export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {
const { controls, overlay, editview, editPanel, viewPanelScene, panelSearch, panelsPerRow, isEditing, scopesBridge } = const {
model.useState(); controls,
overlay,
editview,
editPanel,
viewPanelScene,
panelSearch,
panelsPerRow,
isEditing,
scopesBridge,
layoutOrchestrator,
} = model.useState();
const { placeholder } = layoutOrchestrator.useState();
const { type } = useParams(); const { type } = useParams();
const location = useLocation(); const location = useLocation();
const navIndex = useSelector((state) => state.navIndex); const navIndex = useSelector((state) => state.navIndex);
@ -62,18 +73,21 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
} }
return ( return (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Custom}> <>
{scopesBridge && <scopesBridge.Component model={scopesBridge} />} {placeholder && <placeholder.Component model={placeholder} />}
{editPanel && <editPanel.Component model={editPanel} />} <Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Custom}>
{!editPanel && ( {scopesBridge && <scopesBridge.Component model={scopesBridge} />}
<DashboardEditPaneSplitter {editPanel && <editPanel.Component model={editPanel} />}
dashboard={model} {!editPanel && (
isEditing={isEditing} <DashboardEditPaneSplitter
controls={controls && <controls.Component model={controls} />} dashboard={model}
body={renderBody()} isEditing={isEditing}
/> controls={controls && <controls.Component model={controls} />}
)} body={renderBody()}
{overlay && <overlay.Component model={overlay} />} />
</Page> )}
{overlay && <overlay.Component model={overlay} />}
</Page>
</>
); );
} }

View File

@ -1,4 +1,5 @@
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { createRef } from 'react';
import { Unsubscribable } from 'rxjs'; import { Unsubscribable } from 'rxjs';
import { import {
@ -20,7 +21,8 @@ import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components
import { getCloneKey } from '../../utils/clone'; import { getCloneKey } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils'; 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 { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
import { getDashboardGridItemOptions } from './DashboardGridItemEditor'; import { getDashboardGridItemOptions } from './DashboardGridItemEditor';
@ -52,6 +54,8 @@ export class DashboardGridItem
private _gridSizeSub: Unsubscribable | undefined; private _gridSizeSub: Unsubscribable | undefined;
public containerRef = createRef<HTMLElement>();
public constructor(state: DashboardGridItemState) { public constructor(state: DashboardGridItemState) {
super(state); super(state);
@ -245,4 +249,16 @@ export class DashboardGridItem
public isRepeated(): boolean { public isRepeated(): boolean {
return this.state.variableName !== undefined; 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.');
}
} }

View File

@ -467,7 +467,7 @@ function DefaultGridLayoutManagerRenderer({ model }: SceneComponentProps<Default
const { children } = useSceneObjectState(model.state.grid, { shouldActivateOrKeepAlive: true }); const { children } = useSceneObjectState(model.state.grid, { shouldActivateOrKeepAlive: true });
const dashboard = useDashboard(model); const dashboard = useDashboard(model);
// If we are top level layout and have no children, show empty state // If we are top level layout and we have no children, show empty state
if (model.parent === dashboard && children.length === 0) { if (model.parent === dashboard && children.length === 0) {
return ( return (
<DashboardEmpty dashboard={dashboard} canCreate={!!dashboard.state.meta.canEdit} key="dashboard-empty-state" /> <DashboardEmpty dashboard={dashboard} canCreate={!!dashboard.state.meta.canEdit} key="dashboard-empty-state" />

View File

@ -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<DropZonePlaceholderState> {
static Component = ({ model }: SceneComponentProps<DropZonePlaceholder>) => {
const { width, height, left, top } = model.useState();
const styles = useStyles2(getStyles);
return (
<Portal>
<div
className={cx(styles.placeholder, {
[styles.visible]: width > 0 && height > 0,
})}
style={{ width, height, transform: `translate(${left}px, ${top}px)` }}
/>
</Portal>
);
};
}
const getStyles = (theme: GrafanaTheme2) => ({
placeholder: css({
visibility: 'hidden',
position: 'fixed',
top: 0,
left: 0,
zIndex: -1,
pointerEvents: 'none',
background: theme.colors.primary.transparent,
boxShadow: `0 0 4px ${theme.colors.primary.border}`,
}),
visible: css({
visibility: 'visible',
}),
});

View File

@ -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<DashboardLayoutItem>;
placeholder?: DropZonePlaceholder;
}
export class LayoutOrchestrator extends SceneObjectBase<LayoutOrchestratorState> {
/** 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<SceneLayoutWithDragAndDrop> }) | 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<SceneLayoutWithDragAndDrop> }) | 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<keyof DropZone> = ['top', 'left', 'bottom', 'right'];
return a && b && dims.every((dim) => b[dim] === a[dim]);
}

View File

@ -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<T>(sceneObject: SceneObject, extract: (s: SceneObject) => T | undefined): T | undefined {
let curSceneObject: SceneObject | undefined = sceneObject;
let extracted: T | undefined = undefined;
while (curSceneObject && !extracted) {
extracted = extract(curSceneObject);
curSceneObject = curSceneObject.parent;
}
return extracted;
}
/** Walks up the scene graph, returning the first non-undefined result of `extract` */
export function closestOfType<T extends SceneObject>(
sceneObject: SceneObject,
objectIsOfType: (s: SceneObject) => s is T
): T | undefined {
let curSceneObject: SceneObject | undefined = sceneObject;
while (curSceneObject && !objectIsOfType(curSceneObject)) {
curSceneObject = curSceneObject.parent;
}
return curSceneObject;
}

View File

@ -1,24 +1,26 @@
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { createRef } from 'react';
import { import {
SceneObjectState,
VizPanel,
SceneObjectBase,
sceneGraph,
CustomVariable, CustomVariable,
MultiValueVariable,
VariableValueSingle,
VizPanelState,
SceneVariableSet,
LocalValueVariable, LocalValueVariable,
MultiValueVariable,
sceneGraph,
SceneObjectBase,
SceneObjectState,
SceneVariableSet,
VariableDependencyConfig, VariableDependencyConfig,
VariableValueSingle,
VizPanel,
VizPanelState,
} from '@grafana/scenes'; } from '@grafana/scenes';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { ConditionalRendering } from '../../conditional-rendering/ConditionalRendering'; import { ConditionalRendering } from '../../conditional-rendering/ConditionalRendering';
import { getCloneKey } from '../../utils/clone'; import { getCloneKey } from '../../utils/clone';
import { getMultiVariableValues } from '../../utils/utils'; 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 { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
import { getOptions } from './ResponsiveGridItemEditor'; import { getOptions } from './ResponsiveGridItemEditor';
@ -29,6 +31,7 @@ export interface ResponsiveGridItemState extends SceneObjectState {
hideWhenNoData?: boolean; hideWhenNoData?: boolean;
repeatedPanels?: VizPanel[]; repeatedPanels?: VizPanel[];
variableName?: string; variableName?: string;
isHidden?: boolean;
conditionalRendering?: ConditionalRendering; conditionalRendering?: ConditionalRendering;
} }
@ -40,6 +43,8 @@ export class ResponsiveGridItem extends SceneObjectBase<ResponsiveGridItemState>
onVariableUpdateCompleted: () => this.performRepeat(), onVariableUpdateCompleted: () => this.performRepeat(),
}); });
public readonly isDashboardLayoutItem = true; public readonly isDashboardLayoutItem = true;
public containerRef = createRef<HTMLDivElement>();
public cachedBoundingBox: Rect | undefined;
public constructor(state: ResponsiveGridItemState) { public constructor(state: ResponsiveGridItemState) {
super({ ...state, conditionalRendering: state?.conditionalRendering ?? ConditionalRendering.createEmpty() }); super({ ...state, conditionalRendering: state?.conditionalRendering ?? ConditionalRendering.createEmpty() });
@ -64,10 +69,6 @@ export class ResponsiveGridItem extends SceneObjectBase<ResponsiveGridItemState>
return getOptions(this); return getOptions(this);
} }
public toggleHideWhenNoData() {
this.setState({ hideWhenNoData: !this.state.hideWhenNoData });
}
public performRepeat() { public performRepeat() {
if (!this.state.variableName || sceneGraph.hasVariableDependencyInLoadingState(this)) { if (!this.state.variableName || sceneGraph.hasVariableDependencyInLoadingState(this)) {
return; return;
@ -141,4 +142,78 @@ export class ResponsiveGridItem extends SceneObjectBase<ResponsiveGridItemState>
this.setState(stateUpdate); this.setState(stateUpdate);
this.performRepeat(); 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);
} }

View File

@ -1,15 +1,15 @@
import { css, cx } from '@emotion/css'; import { cx } from '@emotion/css';
import { SceneComponentProps } from '@grafana/scenes'; import { SceneComponentProps } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { useDashboardState, useIsConditionallyHidden } from '../../utils/utils'; import { useDashboardState, useIsConditionallyHidden } from '../../utils/utils';
import { ResponsiveGridItem } from './ResponsiveGridItem'; import { ResponsiveGridItem } from './ResponsiveGridItem';
export function ResponsiveGridItemRenderer({ model }: SceneComponentProps<ResponsiveGridItem>) { export interface ResponsiveGridItemProps extends SceneComponentProps<ResponsiveGridItem> {}
export function ResponsiveGridItemRenderer({ model }: ResponsiveGridItemProps) {
const { body } = model.useState(); const { body } = model.useState();
const style = useStyles2(getStyles);
const { showHiddenElements } = useDashboardState(model); const { showHiddenElements } = useDashboardState(model);
const isConditionallyHidden = useIsConditionallyHidden(model); const isConditionallyHidden = useIsConditionallyHidden(model);
@ -21,27 +21,14 @@ export function ResponsiveGridItemRenderer({ model }: SceneComponentProps<Respon
return model.state.repeatedPanels ? ( return model.state.repeatedPanels ? (
<> <>
{model.state.repeatedPanels.map((item) => ( {model.state.repeatedPanels.map((item) => (
<div <div className={cx({ 'dashboard-visible-hidden-element': isHiddenButVisibleElement })} key={item.state.key}>
className={cx(style.wrapper, isHiddenButVisibleElement && 'dashboard-visible-hidden-element')}
key={item.state.key}
>
<item.Component model={item} /> <item.Component model={item} />
</div> </div>
))} ))}
</> </>
) : ( ) : (
<div className={cx(style.wrapper, isHiddenButVisibleElement && 'dashboard-visible-hidden-element')}> <div className={cx({ 'dashboard-visible-hidden-element': isHiddenButVisibleElement })}>
<body.Component model={body} /> <body.Component model={body} />
</div> </div>
); );
} }
function getStyles() {
return {
wrapper: css({
width: '100%',
height: '100%',
position: 'relative',
}),
};
}

View File

@ -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<ResponsiveGridLayoutState>
implements SceneLayoutWithDragAndDrop
{
public layoutOrchestrator: LayoutOrchestrator | undefined;
public static Component = ResponsiveGridLayoutRenderer;
public containerRef = createRef<HTMLDivElement>();
public activeIndex: number | undefined;
public activeGridCell = { row: 1, column: 1 };
public columnCount = 1;
// maybe not needed?
public rowCount = 1;
public scrollPos: ReturnType<typeof closestScroll> | undefined;
public constructor(state: Partial<ResponsiveGridLayoutState>) {
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;
}

View File

@ -1,9 +1,10 @@
import { SceneCSSGridLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes'; import { SceneQueryRunner, VizPanel } from '@grafana/scenes';
import { findVizPanelByKey } from '../../utils/utils'; import { findVizPanelByKey } from '../../utils/utils';
import { DashboardScene } from '../DashboardScene'; import { DashboardScene } from '../DashboardScene';
import { ResponsiveGridItem } from './ResponsiveGridItem'; import { ResponsiveGridItem } from './ResponsiveGridItem';
import { ResponsiveGridLayout } from './ResponsiveGridLayout';
import { ResponsiveGridLayoutManager } from './ResponsiveGridLayoutManager'; import { ResponsiveGridLayoutManager } from './ResponsiveGridLayoutManager';
describe('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 }); new DashboardScene({ body: manager });

View File

@ -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 { t } from 'app/core/internationalization';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@ -10,10 +10,11 @@ import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
import { ResponsiveGridItem } from './ResponsiveGridItem'; import { ResponsiveGridItem } from './ResponsiveGridItem';
import { ResponsiveGridLayout } from './ResponsiveGridLayout';
import { getEditOptions } from './ResponsiveGridLayoutManagerEditor'; import { getEditOptions } from './ResponsiveGridLayoutManagerEditor';
interface ResponsiveGridLayoutManagerState extends SceneObjectState { interface ResponsiveGridLayoutManagerState extends SceneObjectState {
layout: SceneCSSGridLayout; layout: ResponsiveGridLayout;
} }
export class ResponsiveGridLayoutManager export class ResponsiveGridLayoutManager
@ -149,7 +150,7 @@ export class ResponsiveGridLayoutManager
public static createEmpty(): ResponsiveGridLayoutManager { public static createEmpty(): ResponsiveGridLayoutManager {
return new ResponsiveGridLayoutManager({ return new ResponsiveGridLayoutManager({
layout: new SceneCSSGridLayout({ layout: new ResponsiveGridLayout({
children: [], children: [],
templateColumns: ResponsiveGridLayoutManager.defaultCSS.templateColumns, templateColumns: ResponsiveGridLayoutManager.defaultCSS.templateColumns,
autoRows: ResponsiveGridLayoutManager.defaultCSS.autoRows, autoRows: ResponsiveGridLayoutManager.defaultCSS.autoRows,

View File

@ -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<ResponsiveGridLayout>) {
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 (
<div className={styles.container} ref={model.containerRef}>
<div
style={{
gridRow: model.activeGridCell.row,
gridColumn: model.activeGridCell.column,
display: currentLayoutIsActive && model.activeIndex !== undefined ? 'grid' : 'none',
}}
/>
{children.map((item) => {
const Wrapper = isLazy ? LazyLoader : 'div';
const isDragging = activeLayoutItem === item;
return (
<Wrapper
key={item.state.key!}
className={cx(styles.wrapper, { [styles.dragging]: isDragging })}
style={
isDragging && layoutOrchestrator && item.cachedBoundingBox
? {
width: item.cachedBoundingBox.right - item.cachedBoundingBox.left,
height: item.cachedBoundingBox.bottom - item.cachedBoundingBox.top,
translate: `${-layoutOrchestrator.dragOffset.left}px ${-layoutOrchestrator.dragOffset.top}px`,
// --x/y-pos are set in LayoutOrchestrator
transform: `translate(var(--x-pos), var(--y-pos))`,
}
: {}
}
ref={item.containerRef}
>
<item.Component model={item} />
</Wrapper>
);
})}
</div>
);
}
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',
}),
});

View File

@ -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 { 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) * 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 * Also useful to when rendering / viewing an element outside it's layout scope
@ -11,6 +22,11 @@ export interface DashboardLayoutItem extends SceneObject {
*/ */
isDashboardLayoutItem: true; isDashboardLayoutItem: true;
/**
* Reference to the container DOM element.
*/
containerRef: RefObject<HTMLElement>;
/** /**
* Return layout item options (like repeat, repeat direction, etc. for the default DashboardGridItem) * 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 * When coming out of panel edit
*/ */
editingCompleted?(withChanges: boolean): void; 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 { export function isDashboardLayoutItem(obj: SceneObject): obj is DashboardLayoutItem {

View File

@ -1,7 +1,7 @@
import { SceneCSSGridLayout } from '@grafana/scenes';
import { DashboardV2Spec, ResponsiveGridLayoutItemKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0'; import { DashboardV2Spec, ResponsiveGridLayoutItemKind } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import { ResponsiveGridItem } from '../../scene/layout-responsive-grid/ResponsiveGridItem'; 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 { ResponsiveGridLayoutManager } from '../../scene/layout-responsive-grid/ResponsiveGridLayoutManager';
import { DashboardLayoutManager, LayoutManagerSerializer } from '../../scene/types/DashboardLayoutManager'; import { DashboardLayoutManager, LayoutManagerSerializer } from '../../scene/types/DashboardLayoutManager';
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph'; import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
@ -73,7 +73,7 @@ export class ResponsiveGridLayoutSerializer implements LayoutManagerSerializer {
}); });
return new ResponsiveGridLayoutManager({ return new ResponsiveGridLayoutManager({
layout: new SceneCSSGridLayout({ layout: new ResponsiveGridLayout({
templateColumns: layout.spec.col, templateColumns: layout.spec.col,
autoRows: layout.spec.row, autoRows: layout.spec.row,
children, children,

View File

@ -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 { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0';
import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager'; import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager';
import { ResponsiveGridLayout } from '../../scene/layout-responsive-grid/ResponsiveGridLayout';
import { ResponsiveGridLayoutManager } from '../../scene/layout-responsive-grid/ResponsiveGridLayoutManager'; import { ResponsiveGridLayoutManager } from '../../scene/layout-responsive-grid/ResponsiveGridLayoutManager';
import { RowItem } from '../../scene/layout-rows/RowItem'; import { RowItem } from '../../scene/layout-rows/RowItem';
import { RowItemRepeaterBehavior } from '../../scene/layout-rows/RowItemRepeaterBehavior'; import { RowItemRepeaterBehavior } from '../../scene/layout-rows/RowItemRepeaterBehavior';
@ -232,7 +233,7 @@ describe('serialization', () => {
title: 'Row 1', title: 'Row 1',
isCollapsed: false, isCollapsed: false,
layout: new ResponsiveGridLayoutManager({ layout: new ResponsiveGridLayoutManager({
layout: new SceneCSSGridLayout({ layout: new ResponsiveGridLayout({
children: [], children: [],
templateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', templateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
autoRows: 'minmax(min-content, max-content)', autoRows: 'minmax(min-content, max-content)',

View File

@ -9,7 +9,6 @@ import {
GroupByVariable, GroupByVariable,
IntervalVariable, IntervalVariable,
QueryVariable, QueryVariable,
SceneCSSGridLayout,
SceneGridLayout, SceneGridLayout,
SceneGridRow, SceneGridRow,
SceneRefreshPicker, SceneRefreshPicker,
@ -41,6 +40,7 @@ import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager'; import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior'; import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { ResponsiveGridItem } from '../scene/layout-responsive-grid/ResponsiveGridItem'; 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 { ResponsiveGridLayoutManager } from '../scene/layout-responsive-grid/ResponsiveGridLayoutManager';
import { RowItem } from '../scene/layout-rows/RowItem'; import { RowItem } from '../scene/layout-rows/RowItem';
import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager'; import { RowsLayoutManager } from '../scene/layout-rows/RowsLayoutManager';
@ -475,7 +475,7 @@ describe('dynamic layouts', () => {
rows: [ rows: [
new RowItem({ new RowItem({
layout: new ResponsiveGridLayoutManager({ layout: new ResponsiveGridLayoutManager({
layout: new SceneCSSGridLayout({ layout: new ResponsiveGridLayout({
children: [ children: [
new ResponsiveGridItem({ new ResponsiveGridItem({
body: new VizPanel({}), body: new VizPanel({}),
@ -520,7 +520,7 @@ describe('dynamic layouts', () => {
const scene = setupDashboardScene( const scene = setupDashboardScene(
getMinimalSceneState( getMinimalSceneState(
new ResponsiveGridLayoutManager({ new ResponsiveGridLayoutManager({
layout: new SceneCSSGridLayout({ layout: new ResponsiveGridLayout({
autoRows: 'rowString', autoRows: 'rowString',
templateColumns: 'colString', templateColumns: 'colString',
children: [ children: [