DashboardGrid.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. import classNames from 'classnames';
  2. import React, { PureComponent, CSSProperties } from 'react';
  3. import ReactGridLayout, { ItemCallback } from 'react-grid-layout';
  4. import { connect, ConnectedProps } from 'react-redux';
  5. import AutoSizer from 'react-virtualized-auto-sizer';
  6. import { Subscription } from 'rxjs';
  7. import { config } from '@grafana/runtime';
  8. import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
  9. import { cleanAndRemoveMany } from 'app/features/panel/state/actions';
  10. import { DashboardPanelsChangedEvent } from 'app/types/events';
  11. import { AddPanelWidget } from '../components/AddPanelWidget';
  12. import { DashboardRow } from '../components/DashboardRow';
  13. import { DashboardModel, PanelModel } from '../state';
  14. import { GridPos } from '../state/PanelModel';
  15. import { DashboardPanel } from './DashboardPanel';
  16. export interface OwnProps {
  17. dashboard: DashboardModel;
  18. editPanel: PanelModel | null;
  19. viewPanel: PanelModel | null;
  20. }
  21. export interface State {
  22. isLayoutInitialized: boolean;
  23. }
  24. const mapDispatchToProps = {
  25. cleanAndRemoveMany,
  26. };
  27. const connector = connect(null, mapDispatchToProps);
  28. export type Props = OwnProps & ConnectedProps<typeof connector>;
  29. export class DashboardGridUnconnected extends PureComponent<Props, State> {
  30. private panelMap: { [key: string]: PanelModel } = {};
  31. private eventSubs = new Subscription();
  32. private windowHeight = 1200;
  33. private windowWidth = 1920;
  34. private gridWidth = 0;
  35. /** Used to keep track of mobile panel layout position */
  36. private lastPanelBottom = 0;
  37. constructor(props: Props) {
  38. super(props);
  39. this.state = {
  40. isLayoutInitialized: false,
  41. };
  42. }
  43. componentDidMount() {
  44. const { dashboard } = this.props;
  45. this.eventSubs.add(dashboard.events.subscribe(DashboardPanelsChangedEvent, this.triggerForceUpdate));
  46. }
  47. componentWillUnmount() {
  48. this.eventSubs.unsubscribe();
  49. this.props.cleanAndRemoveMany(Object.keys(this.panelMap));
  50. }
  51. buildLayout() {
  52. const layout = [];
  53. this.panelMap = {};
  54. for (const panel of this.props.dashboard.panels) {
  55. if (!panel.key) {
  56. panel.key = `panel-${panel.id}-${Date.now()}`;
  57. }
  58. this.panelMap[panel.key] = panel;
  59. if (!panel.gridPos) {
  60. console.log('panel without gridpos');
  61. continue;
  62. }
  63. const panelPos: any = {
  64. i: panel.key,
  65. x: panel.gridPos.x,
  66. y: panel.gridPos.y,
  67. w: panel.gridPos.w,
  68. h: panel.gridPos.h,
  69. };
  70. if (panel.type === 'row') {
  71. panelPos.w = GRID_COLUMN_COUNT;
  72. panelPos.h = 1;
  73. panelPos.isResizable = false;
  74. panelPos.isDraggable = panel.collapsed;
  75. }
  76. layout.push(panelPos);
  77. }
  78. return layout;
  79. }
  80. onLayoutChange = (newLayout: ReactGridLayout.Layout[]) => {
  81. for (const newPos of newLayout) {
  82. this.panelMap[newPos.i!].updateGridPos(newPos, this.state.isLayoutInitialized);
  83. }
  84. this.props.dashboard.sortPanelsByGridPos();
  85. // This is called on grid mount as it can correct invalid initial grid positions
  86. if (!this.state.isLayoutInitialized) {
  87. this.setState({ isLayoutInitialized: true });
  88. }
  89. };
  90. triggerForceUpdate = () => {
  91. this.forceUpdate();
  92. };
  93. updateGridPos = (item: ReactGridLayout.Layout, layout: ReactGridLayout.Layout[]) => {
  94. this.panelMap[item.i!].updateGridPos(item);
  95. };
  96. onResize: ItemCallback = (layout, oldItem, newItem) => {
  97. const panel = this.panelMap[newItem.i!];
  98. panel.updateGridPos(newItem);
  99. };
  100. onResizeStop: ItemCallback = (layout, oldItem, newItem) => {
  101. this.updateGridPos(newItem, layout);
  102. };
  103. onDragStop: ItemCallback = (layout, oldItem, newItem) => {
  104. this.updateGridPos(newItem, layout);
  105. };
  106. getPanelScreenPos(panel: PanelModel, gridWidth: number): { top: number; bottom: number } {
  107. let top = 0;
  108. // mobile layout
  109. if (gridWidth < config.theme2.breakpoints.values.md) {
  110. // In mobile layout panels are stacked so we just add the panel vertical margin to the last panel bottom position
  111. top = this.lastPanelBottom + GRID_CELL_VMARGIN;
  112. } else {
  113. // For top position we need to add back the vertical margin removed by translateGridHeightToScreenHeight
  114. top = translateGridHeightToScreenHeight(panel.gridPos.y) + GRID_CELL_VMARGIN;
  115. }
  116. this.lastPanelBottom = top + translateGridHeightToScreenHeight(panel.gridPos.h);
  117. return { top, bottom: this.lastPanelBottom };
  118. }
  119. renderPanels(gridWidth: number) {
  120. const panelElements = [];
  121. // Reset last panel bottom
  122. this.lastPanelBottom = 0;
  123. // This is to avoid layout re-flows, accessing window.innerHeight can trigger re-flow
  124. // We assume here that if width change height might have changed as well
  125. if (this.gridWidth !== gridWidth) {
  126. this.windowHeight = window.innerHeight ?? 1000;
  127. this.windowWidth = window.innerWidth;
  128. this.gridWidth = gridWidth;
  129. }
  130. for (const panel of this.props.dashboard.panels) {
  131. const panelClasses = classNames({ 'react-grid-item--fullscreen': panel.isViewing });
  132. panelElements.push(
  133. <GrafanaGridItem
  134. key={panel.key}
  135. className={panelClasses}
  136. data-panelid={panel.id}
  137. gridPos={panel.gridPos}
  138. gridWidth={gridWidth}
  139. windowHeight={this.windowHeight}
  140. windowWidth={this.windowWidth}
  141. isViewing={panel.isViewing}
  142. >
  143. {(width: number, height: number) => {
  144. return this.renderPanel(panel, width, height);
  145. }}
  146. </GrafanaGridItem>
  147. );
  148. }
  149. return panelElements;
  150. }
  151. renderPanel(panel: PanelModel, width: any, height: any) {
  152. if (panel.type === 'row') {
  153. return <DashboardRow key={panel.key} panel={panel} dashboard={this.props.dashboard} />;
  154. }
  155. if (panel.type === 'add-panel') {
  156. return <AddPanelWidget key={panel.key} panel={panel} dashboard={this.props.dashboard} />;
  157. }
  158. return (
  159. <DashboardPanel
  160. key={panel.key}
  161. stateKey={panel.key}
  162. panel={panel}
  163. dashboard={this.props.dashboard}
  164. isEditing={panel.isEditing}
  165. isViewing={panel.isViewing}
  166. width={width}
  167. height={height}
  168. />
  169. );
  170. }
  171. render() {
  172. const { dashboard } = this.props;
  173. /**
  174. * We have a parent with "flex: 1 1 0" we need to reset it to "flex: 1 1 auto" to have the AutoSizer
  175. * properly working. For more information go here:
  176. * https://github.com/bvaughn/react-virtualized/blob/master/docs/usingAutoSizer.md#can-i-use-autosizer-within-a-flex-container
  177. */
  178. return (
  179. <div style={{ flex: '1 1 auto', display: this.props.editPanel ? 'none' : undefined }}>
  180. <AutoSizer disableHeight>
  181. {({ width }) => {
  182. if (width === 0) {
  183. return null;
  184. }
  185. const draggable = width <= 769 ? false : dashboard.meta.canEdit;
  186. /*
  187. Disable draggable if mobile device, solving an issue with unintentionally
  188. moving panels. https://github.com/grafana/grafana/issues/18497
  189. theme.breakpoints.md = 769
  190. */
  191. return (
  192. /**
  193. * The children is using a width of 100% so we need to guarantee that it is wrapped
  194. * in an element that has the calculated size given by the AutoSizer. The AutoSizer
  195. * has a width of 0 and will let its content overflow its div.
  196. */
  197. <div style={{ width: `${width}px`, height: '100%' }}>
  198. <ReactGridLayout
  199. width={width}
  200. isDraggable={draggable}
  201. isResizable={dashboard.meta.canEdit}
  202. containerPadding={[0, 0]}
  203. useCSSTransforms={false}
  204. margin={[GRID_CELL_VMARGIN, GRID_CELL_VMARGIN]}
  205. cols={GRID_COLUMN_COUNT}
  206. rowHeight={GRID_CELL_HEIGHT}
  207. draggableHandle=".grid-drag-handle"
  208. layout={this.buildLayout()}
  209. onDragStop={this.onDragStop}
  210. onResize={this.onResize}
  211. onResizeStop={this.onResizeStop}
  212. onLayoutChange={this.onLayoutChange}
  213. >
  214. {this.renderPanels(width)}
  215. </ReactGridLayout>
  216. </div>
  217. );
  218. }}
  219. </AutoSizer>
  220. </div>
  221. );
  222. }
  223. }
  224. interface GrafanaGridItemProps extends Record<string, any> {
  225. gridWidth?: number;
  226. gridPos?: GridPos;
  227. isViewing: string;
  228. windowHeight: number;
  229. windowWidth: number;
  230. children: any;
  231. }
  232. /**
  233. * A hacky way to intercept the react-layout-grid item dimensions and pass them to DashboardPanel
  234. */
  235. const GrafanaGridItem = React.forwardRef<HTMLDivElement, GrafanaGridItemProps>((props, ref) => {
  236. const theme = config.theme2;
  237. let width = 100;
  238. let height = 100;
  239. const { gridWidth, gridPos, isViewing, windowHeight, windowWidth, ...divProps } = props;
  240. const style: CSSProperties = props.style ?? {};
  241. if (isViewing) {
  242. // In fullscreen view mode a single panel take up full width & 85% height
  243. width = gridWidth!;
  244. height = windowHeight * 0.85;
  245. style.height = height;
  246. style.width = '100%';
  247. } else if (windowWidth < theme.breakpoints.values.md) {
  248. // Mobile layout is a bit different, every panel take up full width
  249. width = props.gridWidth!;
  250. height = translateGridHeightToScreenHeight(gridPos!.h);
  251. style.height = height;
  252. style.width = '100%';
  253. } else {
  254. // Normal grid layout. The grid framework passes width and height directly to children as style props.
  255. width = parseFloat(props.style.width);
  256. height = parseFloat(props.style.height);
  257. }
  258. // props.children[0] is our main children. RGL adds the drag handle at props.children[1]
  259. return (
  260. <div {...divProps} ref={ref}>
  261. {/* Pass width and height to children as render props */}
  262. {[props.children[0](width, height), props.children.slice(1)]}
  263. </div>
  264. );
  265. });
  266. /**
  267. * This translates grid height dimensions to real pixels
  268. */
  269. function translateGridHeightToScreenHeight(gridHeight: number): number {
  270. return gridHeight * (GRID_CELL_HEIGHT + GRID_CELL_VMARGIN) - GRID_CELL_VMARGIN;
  271. }
  272. GrafanaGridItem.displayName = 'GridItemWithDimensions';
  273. export const DashboardGrid = connector(DashboardGridUnconnected);