mirror of
https://github.com/grafana/grafana.git
synced 2025-08-01 18:44:54 +08:00
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:
@ -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<React.PointerEvent | undefined>();
|
||||
|
||||
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}
|
||||
|
@ -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',
|
||||
|
@ -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<DashboardSceneState> implements LayoutParent {
|
||||
@ -188,6 +192,9 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
|
||||
...state,
|
||||
editPane: new DashboardEditPane(),
|
||||
scopesBridge: config.featureToggles.scopeFilters ? new SceneScopesBridge({}) : undefined,
|
||||
layoutOrchestrator: new LayoutOrchestrator({
|
||||
placeholder: new DropZonePlaceholder({ top: 0, left: 0, width: 0, height: 0 }),
|
||||
}),
|
||||
});
|
||||
|
||||
this.serializer =
|
||||
|
@ -14,8 +14,19 @@ import { PanelSearchLayout } from './PanelSearchLayout';
|
||||
import { DashboardAngularDeprecationBanner } from './angular/DashboardAngularDeprecationBanner';
|
||||
|
||||
export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {
|
||||
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,6 +73,8 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{placeholder && <placeholder.Component model={placeholder} />}
|
||||
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Custom}>
|
||||
{scopesBridge && <scopesBridge.Component model={scopesBridge} />}
|
||||
{editPanel && <editPanel.Component model={editPanel} />}
|
||||
@ -75,5 +88,6 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
|
||||
)}
|
||||
{overlay && <overlay.Component model={overlay} />}
|
||||
</Page>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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<HTMLElement>();
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
@ -467,7 +467,7 @@ function DefaultGridLayoutManagerRenderer({ model }: SceneComponentProps<Default
|
||||
const { children } = useSceneObjectState(model.state.grid, { shouldActivateOrKeepAlive: true });
|
||||
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) {
|
||||
return (
|
||||
<DashboardEmpty dashboard={dashboard} canCreate={!!dashboard.state.meta.canEdit} key="dashboard-empty-state" />
|
||||
|
@ -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',
|
||||
}),
|
||||
});
|
@ -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]);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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<ResponsiveGridItemState>
|
||||
onVariableUpdateCompleted: () => this.performRepeat(),
|
||||
});
|
||||
public readonly isDashboardLayoutItem = true;
|
||||
public containerRef = createRef<HTMLDivElement>();
|
||||
public cachedBoundingBox: Rect | undefined;
|
||||
|
||||
public constructor(state: ResponsiveGridItemState) {
|
||||
super({ ...state, conditionalRendering: state?.conditionalRendering ?? ConditionalRendering.createEmpty() });
|
||||
@ -64,10 +69,6 @@ export class ResponsiveGridItem extends SceneObjectBase<ResponsiveGridItemState>
|
||||
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<ResponsiveGridItemState>
|
||||
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);
|
||||
}
|
||||
|
@ -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<ResponsiveGridItem>) {
|
||||
export interface ResponsiveGridItemProps extends SceneComponentProps<ResponsiveGridItem> {}
|
||||
|
||||
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<Respon
|
||||
return model.state.repeatedPanels ? (
|
||||
<>
|
||||
{model.state.repeatedPanels.map((item) => (
|
||||
<div
|
||||
className={cx(style.wrapper, isHiddenButVisibleElement && 'dashboard-visible-hidden-element')}
|
||||
key={item.state.key}
|
||||
>
|
||||
<div className={cx({ 'dashboard-visible-hidden-element': isHiddenButVisibleElement })} key={item.state.key}>
|
||||
<item.Component model={item} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className={cx(style.wrapper, isHiddenButVisibleElement && 'dashboard-visible-hidden-element')}>
|
||||
<div className={cx({ 'dashboard-visible-hidden-element': isHiddenButVisibleElement })}>
|
||||
<body.Component model={body} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles() {
|
||||
return {
|
||||
wrapper: css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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 });
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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',
|
||||
}),
|
||||
});
|
@ -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<HTMLElement>;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
@ -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,
|
||||
|
@ -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)',
|
||||
|
@ -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: [
|
||||
|
Reference in New Issue
Block a user