mirror of
https://github.com/grafana/grafana.git
synced 2025-01-27 00:37:04 -06:00
Dashboards: Use IntersectionObserver to manage lazy loading of panels (#42834)
This commit is contained in:
parent
12619065a4
commit
597148f23b
@ -253,7 +253,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
|
||||
panel={panel}
|
||||
isEditing={true}
|
||||
isViewing={false}
|
||||
isInView={true}
|
||||
lazy={false}
|
||||
width={panelSize.width}
|
||||
height={panelSize.height}
|
||||
skipStateCleanUp={true}
|
||||
|
@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Props, UnthemedDashboardPage } from './DashboardPage';
|
||||
import { Props as LazyLoaderProps } from '../dashgrid/LazyLoader';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { locationService, setDataSourceSrv } from '@grafana/runtime';
|
||||
import { DashboardModel } from '../state';
|
||||
@ -14,6 +15,13 @@ import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps
|
||||
import { createTheme } from '@grafana/data';
|
||||
import { AutoSizerProps } from 'react-virtualized-auto-sizer';
|
||||
|
||||
jest.mock('app/features/dashboard/dashgrid/LazyLoader', () => {
|
||||
const LazyLoader = ({ children }: Pick<LazyLoaderProps, 'children'>) => {
|
||||
return <>{typeof children === 'function' ? children({ isInView: true }) : children}</>;
|
||||
};
|
||||
return { LazyLoader };
|
||||
});
|
||||
|
||||
jest.mock('app/features/dashboard/components/DashboardSettings/GeneralSettings', () => {
|
||||
class GeneralSettings extends React.Component<{}, {}> {
|
||||
render() {
|
||||
|
@ -3,7 +3,7 @@ import { css } from '@emotion/css';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { CustomScrollbar, ScrollbarPosition, stylesFactory, Themeable2, withTheme2 } from '@grafana/ui';
|
||||
import { CustomScrollbar, stylesFactory, Themeable2, withTheme2 } from '@grafana/ui';
|
||||
|
||||
import { createErrorNotification } from 'app/core/copy/appNotification';
|
||||
import { Branding } from 'app/core/components/Branding/Branding';
|
||||
@ -75,7 +75,6 @@ export type Props = Themeable2 &
|
||||
export interface State {
|
||||
editPanel: PanelModel | null;
|
||||
viewPanel: PanelModel | null;
|
||||
scrollTop: number;
|
||||
updateScrollTop?: number;
|
||||
rememberScrollTop: number;
|
||||
showLoadingState: boolean;
|
||||
@ -92,7 +91,6 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
||||
editPanel: null,
|
||||
viewPanel: null,
|
||||
showLoadingState: false,
|
||||
scrollTop: 0,
|
||||
rememberScrollTop: 0,
|
||||
panelNotFound: false,
|
||||
editPanelAccessDenied: false,
|
||||
@ -252,7 +250,6 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
||||
return {
|
||||
...state,
|
||||
viewPanel: panel,
|
||||
rememberScrollTop: state.scrollTop,
|
||||
updateScrollTop: 0,
|
||||
};
|
||||
}
|
||||
@ -273,10 +270,6 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
||||
return state;
|
||||
}
|
||||
|
||||
setScrollTop = ({ scrollTop }: ScrollbarPosition): void => {
|
||||
this.setState({ scrollTop, updateScrollTop: undefined });
|
||||
};
|
||||
|
||||
onAddPanel = () => {
|
||||
const { dashboard } = this.props;
|
||||
|
||||
@ -320,7 +313,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
||||
|
||||
render() {
|
||||
const { dashboard, isInitSlow, initError, queryParams, theme } = this.props;
|
||||
const { editPanel, viewPanel, scrollTop, updateScrollTop } = this.state;
|
||||
const { editPanel, viewPanel, updateScrollTop } = this.state;
|
||||
const kioskMode = getKioskMode(queryParams.kiosk);
|
||||
const styles = getStyles(theme, kioskMode);
|
||||
|
||||
@ -332,8 +325,6 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only trigger render when the scroll has moved by 25
|
||||
const approximateScrollTop = Math.round(scrollTop / 25) * 25;
|
||||
const inspectPanel = this.getInspectPanel();
|
||||
const containerClassNames = classnames(styles.dashboardContainer, {
|
||||
'panel-in-fullscreen': viewPanel,
|
||||
@ -361,7 +352,6 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
||||
<div className={styles.dashboardScroll}>
|
||||
<CustomScrollbar
|
||||
autoHeightMin="100%"
|
||||
setScrollTop={this.setScrollTop}
|
||||
scrollTop={updateScrollTop}
|
||||
hideHorizontalTrack={true}
|
||||
updateAfterMountMs={500}
|
||||
@ -374,12 +364,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<DashboardGrid
|
||||
dashboard={dashboard}
|
||||
viewPanel={viewPanel}
|
||||
editPanel={editPanel}
|
||||
scrollTop={approximateScrollTop}
|
||||
/>
|
||||
<DashboardGrid dashboard={dashboard} viewPanel={viewPanel} editPanel={editPanel} />
|
||||
</div>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
|
@ -115,7 +115,7 @@ export const SoloPanel = ({ dashboard, notFound, panel, panelId }: SoloPanelProp
|
||||
panel={panel}
|
||||
isEditing={false}
|
||||
isViewing={false}
|
||||
isInView={true}
|
||||
lazy={false}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
|
@ -3,6 +3,13 @@ import { shallow, ShallowWrapper } from 'enzyme';
|
||||
import { DashboardGrid, Props } from './DashboardGrid';
|
||||
import { DashboardModel } from '../state';
|
||||
|
||||
jest.mock('app/features/dashboard/dashgrid/LazyLoader', () => {
|
||||
const LazyLoader: React.FC = ({ children }) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
return { LazyLoader };
|
||||
});
|
||||
|
||||
interface ScenarioContext {
|
||||
props: Props;
|
||||
wrapper?: ShallowWrapper<Props, any, DashboardGrid>;
|
||||
@ -59,7 +66,6 @@ function dashboardGridScenario(description: string, scenarioFn: (ctx: ScenarioCo
|
||||
props: {
|
||||
editPanel: null,
|
||||
viewPanel: null,
|
||||
scrollTop: 0,
|
||||
dashboard: getTestDashboard(),
|
||||
},
|
||||
setProps: (props: Partial<Props>) => {
|
||||
|
@ -21,7 +21,6 @@ export interface Props {
|
||||
dashboard: DashboardModel;
|
||||
editPanel: PanelModel | null;
|
||||
viewPanel: PanelModel | null;
|
||||
scrollTop: number;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
@ -125,32 +124,6 @@ export class DashboardGrid extends PureComponent<Props, State> {
|
||||
this.updateGridPos(newItem, layout);
|
||||
};
|
||||
|
||||
isInView(panel: PanelModel, gridWidth: number) {
|
||||
if (panel.isViewing || panel.isEditing) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const scrollTop = this.props.scrollTop;
|
||||
const screenPos = this.getPanelScreenPos(panel, gridWidth);
|
||||
|
||||
// Show things that are almost in the view
|
||||
const buffer = 100;
|
||||
|
||||
// The panel is above the viewport
|
||||
if (scrollTop > screenPos.bottom + buffer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const scrollViewBottom = scrollTop + this.windowHeight;
|
||||
|
||||
// Panel is below view
|
||||
if (screenPos.top > scrollViewBottom + buffer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !this.props.dashboard.otherPanelInFullscreen(panel);
|
||||
}
|
||||
|
||||
getPanelScreenPos(panel: PanelModel, gridWidth: number): { top: number; bottom: number } {
|
||||
let top = 0;
|
||||
|
||||
@ -185,9 +158,6 @@ export class DashboardGrid extends PureComponent<Props, State> {
|
||||
for (const panel of this.props.dashboard.panels) {
|
||||
const panelClasses = classNames({ 'react-grid-item--fullscreen': panel.isViewing });
|
||||
|
||||
// Update is in view state
|
||||
panel.isInView = this.isInView(panel, gridWidth);
|
||||
|
||||
panelElements.push(
|
||||
<GrafanaGridItem
|
||||
key={panel.key}
|
||||
@ -226,7 +196,6 @@ export class DashboardGrid extends PureComponent<Props, State> {
|
||||
dashboard={this.props.dashboard}
|
||||
isEditing={panel.isEditing}
|
||||
isViewing={panel.isViewing}
|
||||
isInView={panel.isInView}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
@ -235,13 +204,14 @@ export class DashboardGrid extends PureComponent<Props, State> {
|
||||
|
||||
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 (
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
<div style={{ flex: '1 1 auto' }}>
|
||||
<div style={{ flex: '1 1 auto', display: this.props.editPanel ? 'none' : undefined }}>
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => {
|
||||
if (width === 0) {
|
||||
|
@ -7,6 +7,7 @@ import { StoreState } from 'app/types';
|
||||
import { PanelPlugin } from '@grafana/data';
|
||||
import { cleanUpPanelState, setPanelInstanceState } from '../../panel/state/reducers';
|
||||
import { initPanelState } from '../../panel/state/actions';
|
||||
import { LazyLoader } from './LazyLoader';
|
||||
|
||||
export interface OwnProps {
|
||||
panel: PanelModel;
|
||||
@ -14,14 +15,10 @@ export interface OwnProps {
|
||||
dashboard: DashboardModel;
|
||||
isEditing: boolean;
|
||||
isViewing: boolean;
|
||||
isInView: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
skipStateCleanUp?: boolean;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
isLazy: boolean;
|
||||
lazy?: boolean;
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: StoreState, props: OwnProps) => {
|
||||
@ -46,18 +43,15 @@ const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
export type Props = OwnProps & ConnectedProps<typeof connector>;
|
||||
|
||||
export class DashboardPanelUnconnected extends PureComponent<Props, State> {
|
||||
export class DashboardPanelUnconnected extends PureComponent<Props> {
|
||||
static defaultProps: Partial<Props> = {
|
||||
lazy: true,
|
||||
};
|
||||
|
||||
specialPanels: { [key: string]: Function } = {};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isLazy: !props.isInView,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.panel.isInView = !this.props.lazy;
|
||||
if (!this.props.plugin) {
|
||||
this.props.initPanelState(this.props.panel);
|
||||
}
|
||||
@ -70,21 +64,19 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.state.isLazy && this.props.isInView) {
|
||||
this.setState({ isLazy: false });
|
||||
}
|
||||
}
|
||||
|
||||
onInstanceStateChange = (value: any) => {
|
||||
this.props.setPanelInstanceState({ key: this.props.stateKey, value });
|
||||
};
|
||||
|
||||
renderPanel(plugin: PanelPlugin) {
|
||||
const { dashboard, panel, isViewing, isInView, isEditing, width, height } = this.props;
|
||||
onVisibilityChange = (v: boolean) => {
|
||||
this.props.panel.isInView = v;
|
||||
};
|
||||
|
||||
if (plugin.angularPanelCtrl) {
|
||||
return (
|
||||
renderPanel(plugin: PanelPlugin) {
|
||||
const { dashboard, panel, isViewing, isEditing, width, height, lazy } = this.props;
|
||||
|
||||
const renderPanelChrome = (isInView: boolean) =>
|
||||
plugin.angularPanelCtrl ? (
|
||||
<PanelChromeAngular
|
||||
plugin={plugin}
|
||||
panel={panel}
|
||||
@ -95,38 +87,37 @@ export class DashboardPanelUnconnected extends PureComponent<Props, State> {
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
) : (
|
||||
<PanelChrome
|
||||
plugin={plugin}
|
||||
panel={panel}
|
||||
dashboard={dashboard}
|
||||
isViewing={isViewing}
|
||||
isEditing={isEditing}
|
||||
isInView={isInView}
|
||||
width={width}
|
||||
height={height}
|
||||
onInstanceStateChange={this.onInstanceStateChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelChrome
|
||||
plugin={plugin}
|
||||
panel={panel}
|
||||
dashboard={dashboard}
|
||||
isViewing={isViewing}
|
||||
isEditing={isEditing}
|
||||
isInView={isInView}
|
||||
width={width}
|
||||
height={height}
|
||||
onInstanceStateChange={this.onInstanceStateChange}
|
||||
/>
|
||||
return lazy ? (
|
||||
<LazyLoader width={width} height={height} onChange={this.onVisibilityChange}>
|
||||
{({ isInView }) => renderPanelChrome(isInView)}
|
||||
</LazyLoader>
|
||||
) : (
|
||||
renderPanelChrome(true)
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { plugin } = this.props;
|
||||
const { isLazy } = this.state;
|
||||
|
||||
// If we have not loaded plugin exports yet, wait
|
||||
if (!plugin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If we are lazy state don't render anything
|
||||
if (isLazy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.renderPanel(plugin);
|
||||
}
|
||||
}
|
||||
|
57
public/app/features/dashboard/dashgrid/LazyLoader.tsx
Normal file
57
public/app/features/dashboard/dashgrid/LazyLoader.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useUniqueId } from 'app/plugins/datasource/influxdb/components/useUniqueId';
|
||||
import { useEffectOnce } from 'react-use';
|
||||
|
||||
export interface Props {
|
||||
children: React.ReactNode | (({ isInView }: { isInView: boolean }) => React.ReactNode);
|
||||
width?: number;
|
||||
height?: number;
|
||||
onLoad?: () => void;
|
||||
onChange?: (isInView: boolean) => void;
|
||||
}
|
||||
|
||||
export function LazyLoader({ children, width, height, onLoad, onChange }: Props) {
|
||||
const id = useUniqueId();
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [isInView, setIsInView] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
useEffectOnce(() => {
|
||||
LazyLoader.addCallback(id, (entry) => {
|
||||
if (!loaded && entry.isIntersecting) {
|
||||
setLoaded(true);
|
||||
onLoad?.();
|
||||
}
|
||||
|
||||
setIsInView(entry.isIntersecting);
|
||||
onChange?.(entry.isIntersecting);
|
||||
});
|
||||
|
||||
if (wrapperRef.current) {
|
||||
LazyLoader.observer.observe(wrapperRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
delete LazyLoader.callbacks[id];
|
||||
if (Object.keys(LazyLoader.callbacks).length === 0) {
|
||||
LazyLoader.observer.disconnect();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div id={id} ref={wrapperRef} style={{ width, height }}>
|
||||
{loaded && (typeof children === 'function' ? children({ isInView }) : children)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LazyLoader.callbacks = {} as Record<string, (e: IntersectionObserverEntry) => void>;
|
||||
LazyLoader.addCallback = (id: string, c: (e: IntersectionObserverEntry) => void) => (LazyLoader.callbacks[id] = c);
|
||||
LazyLoader.observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
LazyLoader.callbacks[entry.target.id](entry);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '100px' }
|
||||
);
|
@ -4,6 +4,7 @@ exports[`DashboardGrid Can render dashboard grid Should render 1`] = `
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"display": undefined,
|
||||
"flex": "1 1 auto",
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user