import classNames from 'classnames'; import React, { PureComponent, CSSProperties } from 'react'; import ReactGridLayout, { ItemCallback } from 'react-grid-layout'; import { connect, ConnectedProps } from 'react-redux'; import AutoSizer from 'react-virtualized-auto-sizer'; import { Subscription } from 'rxjs'; import { config } from '@grafana/runtime'; import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants'; import { cleanAndRemoveMany } from 'app/features/panel/state/actions'; import { DashboardPanelsChangedEvent } from 'app/types/events'; import { AddPanelWidget } from '../components/AddPanelWidget'; import { DashboardRow } from '../components/DashboardRow'; import { DashboardModel, PanelModel } from '../state'; import { GridPos } from '../state/PanelModel'; import { DashboardPanel } from './DashboardPanel'; export interface OwnProps { dashboard: DashboardModel; editPanel: PanelModel | null; viewPanel: PanelModel | null; } export interface State { isLayoutInitialized: boolean; } const mapDispatchToProps = { cleanAndRemoveMany, }; const connector = connect(null, mapDispatchToProps); export type Props = OwnProps & ConnectedProps; export class DashboardGridUnconnected extends PureComponent { private panelMap: { [key: string]: PanelModel } = {}; private eventSubs = new Subscription(); private windowHeight = 1200; private windowWidth = 1920; private gridWidth = 0; /** Used to keep track of mobile panel layout position */ private lastPanelBottom = 0; constructor(props: Props) { super(props); this.state = { isLayoutInitialized: false, }; } componentDidMount() { const { dashboard } = this.props; this.eventSubs.add(dashboard.events.subscribe(DashboardPanelsChangedEvent, this.triggerForceUpdate)); } componentWillUnmount() { this.eventSubs.unsubscribe(); this.props.cleanAndRemoveMany(Object.keys(this.panelMap)); } buildLayout() { const layout = []; this.panelMap = {}; for (const panel of this.props.dashboard.panels) { if (!panel.key) { panel.key = `panel-${panel.id}-${Date.now()}`; } this.panelMap[panel.key] = panel; if (!panel.gridPos) { console.log('panel without gridpos'); continue; } const panelPos: any = { i: panel.key, x: panel.gridPos.x, y: panel.gridPos.y, w: panel.gridPos.w, h: panel.gridPos.h, }; if (panel.type === 'row') { panelPos.w = GRID_COLUMN_COUNT; panelPos.h = 1; panelPos.isResizable = false; panelPos.isDraggable = panel.collapsed; } layout.push(panelPos); } return layout; } onLayoutChange = (newLayout: ReactGridLayout.Layout[]) => { for (const newPos of newLayout) { this.panelMap[newPos.i!].updateGridPos(newPos, this.state.isLayoutInitialized); } this.props.dashboard.sortPanelsByGridPos(); // This is called on grid mount as it can correct invalid initial grid positions if (!this.state.isLayoutInitialized) { this.setState({ isLayoutInitialized: true }); } }; triggerForceUpdate = () => { this.forceUpdate(); }; updateGridPos = (item: ReactGridLayout.Layout, layout: ReactGridLayout.Layout[]) => { this.panelMap[item.i!].updateGridPos(item); }; onResize: ItemCallback = (layout, oldItem, newItem) => { const panel = this.panelMap[newItem.i!]; panel.updateGridPos(newItem); }; onResizeStop: ItemCallback = (layout, oldItem, newItem) => { this.updateGridPos(newItem, layout); }; onDragStop: ItemCallback = (layout, oldItem, newItem) => { this.updateGridPos(newItem, layout); }; getPanelScreenPos(panel: PanelModel, gridWidth: number): { top: number; bottom: number } { let top = 0; // mobile layout if (gridWidth < config.theme2.breakpoints.values.md) { // In mobile layout panels are stacked so we just add the panel vertical margin to the last panel bottom position top = this.lastPanelBottom + GRID_CELL_VMARGIN; } else { // For top position we need to add back the vertical margin removed by translateGridHeightToScreenHeight top = translateGridHeightToScreenHeight(panel.gridPos.y) + GRID_CELL_VMARGIN; } this.lastPanelBottom = top + translateGridHeightToScreenHeight(panel.gridPos.h); return { top, bottom: this.lastPanelBottom }; } renderPanels(gridWidth: number) { const panelElements = []; // Reset last panel bottom this.lastPanelBottom = 0; // This is to avoid layout re-flows, accessing window.innerHeight can trigger re-flow // We assume here that if width change height might have changed as well if (this.gridWidth !== gridWidth) { this.windowHeight = window.innerHeight ?? 1000; this.windowWidth = window.innerWidth; this.gridWidth = gridWidth; } for (const panel of this.props.dashboard.panels) { const panelClasses = classNames({ 'react-grid-item--fullscreen': panel.isViewing }); panelElements.push( {(width: number, height: number) => { return this.renderPanel(panel, width, height); }} ); } return panelElements; } renderPanel(panel: PanelModel, width: any, height: any) { if (panel.type === 'row') { return ; } if (panel.type === 'add-panel') { return ; } return ( ); } render() { const { dashboard } = this.props; /** * We have a parent with "flex: 1 1 0" we need to reset it to "flex: 1 1 auto" to have the AutoSizer * properly working. For more information go here: * https://github.com/bvaughn/react-virtualized/blob/master/docs/usingAutoSizer.md#can-i-use-autosizer-within-a-flex-container */ return (
{({ width }) => { if (width === 0) { return null; } const draggable = width <= 769 ? false : dashboard.meta.canEdit; /* Disable draggable if mobile device, solving an issue with unintentionally moving panels. https://github.com/grafana/grafana/issues/18497 theme.breakpoints.md = 769 */ return ( /** * The children is using a width of 100% so we need to guarantee that it is wrapped * in an element that has the calculated size given by the AutoSizer. The AutoSizer * has a width of 0 and will let its content overflow its div. */
{this.renderPanels(width)}
); }}
); } } interface GrafanaGridItemProps extends Record { gridWidth?: number; gridPos?: GridPos; isViewing: string; windowHeight: number; windowWidth: number; children: any; } /** * A hacky way to intercept the react-layout-grid item dimensions and pass them to DashboardPanel */ const GrafanaGridItem = React.forwardRef((props, ref) => { const theme = config.theme2; let width = 100; let height = 100; const { gridWidth, gridPos, isViewing, windowHeight, windowWidth, ...divProps } = props; const style: CSSProperties = props.style ?? {}; if (isViewing) { // In fullscreen view mode a single panel take up full width & 85% height width = gridWidth!; height = windowHeight * 0.85; style.height = height; style.width = '100%'; } else if (windowWidth < theme.breakpoints.values.md) { // Mobile layout is a bit different, every panel take up full width width = props.gridWidth!; height = translateGridHeightToScreenHeight(gridPos!.h); style.height = height; style.width = '100%'; } else { // Normal grid layout. The grid framework passes width and height directly to children as style props. width = parseFloat(props.style.width); height = parseFloat(props.style.height); } // props.children[0] is our main children. RGL adds the drag handle at props.children[1] return (
{/* Pass width and height to children as render props */} {[props.children[0](width, height), props.children.slice(1)]}
); }); /** * This translates grid height dimensions to real pixels */ function translateGridHeightToScreenHeight(gridHeight: number): number { return gridHeight * (GRID_CELL_HEIGHT + GRID_CELL_VMARGIN) - GRID_CELL_VMARGIN; } GrafanaGridItem.displayName = 'GridItemWithDimensions'; export const DashboardGrid = connector(DashboardGridUnconnected);