Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion public/app/core/reducers/fn-slice.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { fnStateProps } from './fn-slice';
import { fnStateProps, INITIAL_FN_STATE } from './fn-slice';

describe('fn-slice', () => {
it('includes the host portal container id in copied microfrontend state props', () => {
expect(fnStateProps).toContain('portalContainerID');
});

it('copies the explicit panel preload opt-in and leaves it disabled by default', () => {
expect(fnStateProps).toContain('preloadPanels');
expect(INITIAL_FN_STATE.preloadPanels).toBe(false);
});
});
5 changes: 5 additions & 0 deletions public/app/core/reducers/fn-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface FnState {
version: number;
controlsContainer: string | null;
dashboardAccessMode: 'standard' | 'custom';
/** Host opt-in for eager canned-panel loading; never grants query access. */
readonly preloadPanels?: boolean;
pageTitle: string;
queryParams: AnyObject;
hiddenVariables: string[];
Expand Down Expand Up @@ -76,6 +78,7 @@ export type FnPropMappedFromState = Extract<
| 'version'
| 'controlsContainer'
| 'dashboardAccessMode'
| 'preloadPanels'
| 'enablePanelEdit'
| 'enablePanelDelete'
| 'enablePanelLayoutEdit'
Expand All @@ -96,6 +99,7 @@ export type FnPropsMappedFromState = Pick<FnGlobalState, FnPropMappedFromState>;
export const fnStateProps: FnStateProp[] = [
'controlsContainer',
'dashboardAccessMode',
'preloadPanels',
'enablePanelEdit',
'enablePanelDelete',
'enablePanelLayoutEdit',
Expand Down Expand Up @@ -123,6 +127,7 @@ export const INITIAL_FN_STATE: FnState = {
version: 1,
controlsContainer: null,
dashboardAccessMode: 'standard',
preloadPanels: false,
pageTitle: '',
queryParams: {},
hiddenVariables: [],
Expand Down
93 changes: 85 additions & 8 deletions public/app/features/dashboard/dashgrid/DashboardGrid.test.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,40 @@
import { act, render, screen } from '@testing-library/react';
import { ReactNode } from 'react';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { useEffectOnce } from 'react-use';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';

import { TextBoxVariableModel } from '@grafana/data';
import { GrafanaThemeType, TextBoxVariableModel } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { Dashboard } from '@grafana/schema';
import appEvents from 'app/core/app_events';
import { GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
import { GrafanaContext } from 'app/core/context/GrafanaContext';
import { INITIAL_FN_STATE } from 'app/core/reducers/fn-slice';
import { GetVariables } from 'app/features/variables/state/selectors';
import { VariablesChanged } from 'app/features/variables/types';
import { configureStore } from 'app/store/configureStore';
import { DashboardMeta } from 'app/types';
import { DashboardMeta, StoreState } from 'app/types';

import { DashboardModel, PanelModel } from '../state';
import { createDashboardModelFixture } from '../state/__fixtures__/dashboardFixtures';

import { Component, DashboardGrid, Props } from './DashboardGrid';
import { Props as LazyLoaderProps } from './LazyLoader';
import { LazyLoader, Props as LazyLoaderProps } from './LazyLoader';

// A bundled panel's package mock omits SceneDataLayerBase; this suite exercises
// the real dashboard graph, not that plugin's isolated scene stub.
jest.unmock('@grafana/scenes');
jest.unmock('@grafana/data');
jest.unmock('@grafana/ui');

// JSDOM has no layout; provide the viewport the grid needs to mount its panels.
jest.mock('react-virtualized-auto-sizer', () => ({
__esModule: true,
default: ({ children }: { children: (size: { width: number; height: number }) => ReactNode }) =>
children({ width: 1000, height: 800 }),
}));

jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
Expand All @@ -32,18 +47,33 @@ jest.mock('@grafana/runtime', () => ({
}));

jest.mock('app/features/dashboard/dashgrid/LazyLoader', () => {
const LazyLoader = ({ children, onLoad }: Pick<LazyLoaderProps, 'children' | 'onLoad'>) => {
const LazyLoader = jest.fn(({ children, onLoad }: Pick<LazyLoaderProps, 'children' | 'onLoad'>) => {
useEffectOnce(() => {
onLoad?.();
});
return <>{typeof children === 'function' ? children({ isInView: true }) : children}</>;
};
});
return { LazyLoader };
});

function setup(props: Props) {
function setup(props: Props, initialState: Partial<StoreState> = {}) {
const context = getGrafanaContextMock();
const store = configureStore({});
const store = configureStore({
fnGlobalState: {
...INITIAL_FN_STATE,
FNDashboard: props.isFnDashboard ?? false,
mode: GrafanaThemeType.Light,
portalContainerID: props.portalContainerID ?? INITIAL_FN_STATE.portalContainerID,
},
...initialState,
});
const container = document.createElement('div');
container.id = props.portalContainerID ?? '';
Object.defineProperties(container, {
clientWidth: { value: 1000 },
clientHeight: { value: 800 },
});
document.body.appendChild(container);

return render(
<GrafanaContext.Provider value={context}>
Expand All @@ -52,7 +82,8 @@ function setup(props: Props) {
<DashboardGrid {...props} />
</Router>
</Provider>
</GrafanaContext.Provider>
</GrafanaContext.Provider>,
{ container }
);
}

Expand Down Expand Up @@ -122,6 +153,52 @@ function getTestDashboard(
}

describe('DashboardGrid', () => {
let fetchSpy: jest.SpyInstance;
beforeAll(() => {
fetchSpy = jest.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (!url.endsWith('.svg')) {
throw new Error(`Unexpected grid test fetch: ${url}`);
}
return new Response('<svg xmlns="http://www.w3.org/2000/svg" />');
});
});
afterAll(() => fetchSpy.mockRestore());

it.each<{
readonly FNDashboard: boolean;
readonly dashboardAccessMode: 'standard' | 'custom';
readonly preloadPanels: boolean;
readonly lazy: boolean;
}>([
{ FNDashboard: true, dashboardAccessMode: 'standard', preloadPanels: true, lazy: false },
{ FNDashboard: true, dashboardAccessMode: 'standard', preloadPanels: false, lazy: true },
{ FNDashboard: true, dashboardAccessMode: 'custom', preloadPanels: true, lazy: true },
{ FNDashboard: false, dashboardAccessMode: 'standard', preloadPanels: true, lazy: true },
])('only preloads explicitly opted-in embedded standard dashboards: %j', async ({ lazy, ...fnState }) => {
jest.mocked(LazyLoader).mockClear();
setup(
{ editPanel: null, viewPanel: null, isEditable: false, dashboard: getTestDashboard() },
{ fnGlobalState: { ...INITIAL_FN_STATE, mode: GrafanaThemeType.Light, ...fnState } }
);
expect(await screen.findByText('My gauge')).toBeInTheDocument();
expect(jest.mocked(LazyLoader).mock.calls.length).toBeGreaterThan(0);
expect(jest.mocked(LazyLoader).mock.calls.every(([props]) => Boolean(props.preload) === !lazy)).toBe(true);
});

it.each([undefined, false, true])('passes preload=%s to the existing panel loading path', (preloadPanels) => {
const dashboard = getTestDashboard();
const grid = new Component({
editPanel: null,
viewPanel: null,
isEditable: false,
dashboard,
preloadPanels,
});
const rendered = grid.renderPanel(getRequiredPanel(dashboard, 4), 800, 300, false);
expect(rendered.props.preload).toBe(preloadPanels);
});

it('Should render panels', async () => {
const props: Props = {
editPanel: null,
Expand Down
6 changes: 6 additions & 0 deletions public/app/features/dashboard/dashgrid/DashboardGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface Props {
viewPanel: PanelModel | null;
hidePanelMenus?: boolean;
isFnDashboard?: boolean;
preloadPanels?: boolean;
onLayoutUpdate?: () => void;
portalContainerID?: string;
}
Expand Down Expand Up @@ -453,6 +454,7 @@ export class Component extends PureComponent<Props, State> {
width={width}
height={height}
hideMenu={this.props.hidePanelMenus}
preload={this.props.preloadPanels}
/>
);
}
Expand Down Expand Up @@ -624,6 +626,10 @@ GrafanaGridItem.displayName = 'GridItemWithDimensions';
function mapStateToProps() {
return (state: StoreState) => ({
isFnDashboard: state.fnGlobalState.FNDashboard,
preloadPanels:
state.fnGlobalState.FNDashboard &&
state.fnGlobalState.dashboardAccessMode === 'standard' &&
state.fnGlobalState.preloadPanels === true,
portalContainerID: state.fnGlobalState.portalContainerID,
});
}
Expand Down
12 changes: 10 additions & 2 deletions public/app/features/dashboard/dashgrid/DashboardPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface OwnProps {
width: number;
height: number;
lazy?: boolean;
preload?: boolean;
timezone?: string;
hideMenu?: boolean;
}
Expand Down Expand Up @@ -114,6 +115,7 @@ export class DashboardPanelUnconnected extends PureComponent<Props> {
isViewing={isViewing}
isEditing={isEditing}
isInView={isInView}
preloadInitialQuery={this.props.preload}
isDraggable={isDraggable}
width={width}
height={height}
Expand All @@ -125,10 +127,16 @@ export class DashboardPanelUnconnected extends PureComponent<Props> {
};

render() {
const { width, height, lazy } = this.props;
const { width, height, lazy, preload } = this.props;

return lazy ? (
<LazyLoader width={width} height={height} onChange={this.onVisibilityChange} onLoad={this.onPanelLoad}>
<LazyLoader
width={width}
height={height}
preload={preload}
onChange={this.onVisibilityChange}
onLoad={this.onPanelLoad}
>
{this.renderPanel}
</LazyLoader>
) : (
Expand Down
73 changes: 73 additions & 0 deletions public/app/features/dashboard/dashgrid/LazyLoader.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { act, render, screen } from '@testing-library/react';

import { LazyLoader } from './LazyLoader';

describe('LazyLoader preload', () => {
beforeEach(() => {
jest.spyOn(LazyLoader.observer, 'observe').mockImplementation(() => {});
});

afterEach(() => jest.restoreAllMocks());

it('accepts a later host opt-in without repeating the load or losing visibility', () => {
const onLoad = jest.fn();
const child = ({ isInView }: { isInView: boolean }) => <span>{isInView ? 'visible' : 'off-screen'}</span>;
const { rerender } = render(<LazyLoader onLoad={onLoad}>{child}</LazyLoader>);
expect(onLoad).not.toHaveBeenCalled();
rerender(
<LazyLoader onLoad={onLoad} preload>
{child}
</LazyLoader>
);
expect(screen.getByText('off-screen')).toBeInTheDocument();
expect(onLoad).toHaveBeenCalledTimes(1);
rerender(
<LazyLoader onLoad={onLoad} preload={false}>
{child}
</LazyLoader>
);
rerender(
<LazyLoader onLoad={onLoad} preload>
{child}
</LazyLoader>
);
expect(screen.getByText('off-screen')).toBeInTheDocument();
expect(onLoad).toHaveBeenCalledTimes(1);
});

it.each([false, true])('keeps actual visibility and loads only once with preload=%s', (preload) => {
const onLoad = jest.fn();
const onChange = jest.fn();
const unobserve = jest.spyOn(LazyLoader.observer, 'unobserve');
const { container, unmount } = render(
<LazyLoader preload={preload} onLoad={onLoad} onChange={onChange}>
{({ isInView }) => <span>{isInView ? 'visible panel' : 'off-screen panel'}</span>}
</LazyLoader>
);
expect(onLoad).toHaveBeenCalledTimes(preload ? 1 : 0);
expect(screen.queryByText('off-screen panel') !== null).toBe(preload);
const element = container.firstElementChild!;
const changeVisibility = (isIntersecting: boolean) => {
const entry: IntersectionObserverEntry = {
target: element,
isIntersecting,
intersectionRatio: isIntersecting ? 1 : 0,
time: 0,
boundingClientRect: element.getBoundingClientRect(),
intersectionRect: element.getBoundingClientRect(),
rootBounds: null,
};
act(() => LazyLoader.callbacks[element.id](entry));
};
changeVisibility(true);
expect(screen.getByText('visible panel')).toBeInTheDocument();
changeVisibility(false);
expect(screen.getByText('off-screen panel')).toBeInTheDocument();
changeVisibility(true);
expect(onLoad).toHaveBeenCalledTimes(1);
expect(onChange.mock.calls).toEqual([[true], [false], [true]]);
unmount();
expect(unobserve).toHaveBeenCalledWith(element);
expect(LazyLoader.callbacks[element.id]).toBeUndefined();
});
});
20 changes: 17 additions & 3 deletions public/app/features/dashboard/dashgrid/LazyLoader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useId, useRef, useState } from 'react';
import { useEffect, useId, useRef, useState } from 'react';
import { useEffectOnce } from 'react-use';

export interface Props {
Expand All @@ -7,17 +7,21 @@ export interface Props {
height?: number;
onLoad?: () => void;
onChange?: (isInView: boolean) => void;
/** Mount initially, while retaining actual viewport visibility for refreshes. */
preload?: boolean;
}

export function LazyLoader({ children, width, height, onLoad, onChange }: Props) {
export function LazyLoader({ children, width, height, onLoad, onChange, preload = false }: Props) {
const id = useId();
const [loaded, setLoaded] = useState(false);
const hasLoaded = useRef(false);
const [isInView, setIsInView] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);

useEffectOnce(() => {
LazyLoader.addCallback(id, (entry) => {
if (!loaded && entry.isIntersecting) {
if (!hasLoaded.current && entry.isIntersecting) {
hasLoaded.current = true;
setLoaded(true);
onLoad?.();
}
Expand All @@ -41,6 +45,16 @@ export function LazyLoader({ children, width, height, onLoad, onChange }: Props)
};
});

useEffect(() => {
// Host settings may reach an existing dashboard store after its first render.
// Do not remount or reload panels that already entered the viewport.
if (preload && !hasLoaded.current) {
hasLoaded.current = true;
setLoaded(true);
onLoad?.();
}
}, [preload, onLoad]);

return (
<div id={id} ref={wrapperRef} style={{ width, height }}>
{loaded && (typeof children === 'function' ? children({ isInView }) : children)}
Expand Down
Loading
Loading