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,
|
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}
|
||||||
|
@ -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',
|
||||||
|
@ -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 =
|
||||||
|
@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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" />
|
||||||
|
@ -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 { 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);
|
||||||
}
|
}
|
||||||
|
@ -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',
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
@ -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 { 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 });
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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 { 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 {
|
||||||
|
@ -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,
|
||||||
|
@ -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)',
|
||||||
|
@ -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: [
|
||||||
|
Reference in New Issue
Block a user