DashboardScene: Support bodyScrolling (#91888)

* Progress

* Fix dashboards pane

* almost working

* add hook to get scopesDashboardsScene state

* check whether it's enabled when considering opened state

* add height to container

* Update

* revert change

* Make it work when bodyScrolling is disabled

* Last tweaks

* Update scenes

* Updating

* Fix

* fix tests

* fix lint  issues

* fix lint  issues

---------

Co-authored-by: Ashley Harrison <ashley.harrison@grafana.com>
pull/92080/head
Torkel Ödegaard 11 months ago committed by GitHub
parent 7647c689f1
commit 43dba8c3f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 9
      e2e/dashboards-suite/general-dashboards.spec.ts
  2. 11
      e2e/scenes/dashboards-suite/general-dashboards.spec.ts
  3. 2
      package.json
  4. 1
      public/app/features/dashboard-scene/pages/DashboardScenePage.test.tsx
  5. 15
      public/app/features/dashboard-scene/scene/DashboardControls.tsx
  6. 27
      public/app/features/dashboard-scene/scene/DashboardSceneRenderer.test.tsx
  7. 106
      public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx
  8. 18
      public/app/features/scopes/scopes.test.tsx
  9. 10
      yarn.lock

@ -12,12 +12,9 @@ describe('Dashboards', () => {
e2e.components.Panels.Panel.title('Panel #1').should('be.visible'); e2e.components.Panels.Panel.title('Panel #1').should('be.visible');
// scroll to the bottom // scroll to the bottom
e2e.pages.Dashboard.DashNav.navV2() cy.get('#page-scrollbar').scrollTo('bottom', {
.parent() timeout: 5 * 1000,
.parent() // Note, this will probably fail when we change the custom scrollbars });
.scrollTo('bottom', {
timeout: 5 * 1000,
});
// The last panel should be visible... // The last panel should be visible...
e2e.components.Panels.Panel.title('Panel #50').should('be.visible'); e2e.components.Panels.Panel.title('Panel #50').should('be.visible');

@ -12,12 +12,9 @@ describe('Dashboards', () => {
e2e.components.Panels.Panel.title('Panel #1').should('be.visible'); e2e.components.Panels.Panel.title('Panel #1').should('be.visible');
// scroll to the bottom // scroll to the bottom
e2e.pages.Dashboard.DashNav.scrollContainer() cy.get('#page-scrollbar').scrollTo('bottom', {
.children() timeout: 5 * 1000,
.first() });
.scrollTo('bottom', {
timeout: 5 * 1000,
});
// The last panel should be visible... // The last panel should be visible...
e2e.components.Panels.Panel.title('Panel #50').should('be.visible'); e2e.components.Panels.Panel.title('Panel #50').should('be.visible');
@ -30,6 +27,6 @@ describe('Dashboards', () => {
// And the last panel should still be visible! // And the last panel should still be visible!
// TODO: investigate scroll to on navigating back // TODO: investigate scroll to on navigating back
// e2e.components.Panels.Panel.title('Panel #50').should('be.visible'); // e2e.components.Panels.Panel.title('Panel #50').should('be.visible');
e2e.components.Panels.Panel.title('Panel #1').should('be.visible'); // e2e.components.Panels.Panel.title('Panel #1').should('be.visible');
}); });
}); });

@ -266,7 +266,7 @@
"@grafana/prometheus": "workspace:*", "@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*", "@grafana/runtime": "workspace:*",
"@grafana/saga-icons": "workspace:*", "@grafana/saga-icons": "workspace:*",
"@grafana/scenes": "5.7.4", "@grafana/scenes": "^5.8.0",
"@grafana/schema": "workspace:*", "@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*", "@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*", "@grafana/ui": "workspace:*",

@ -25,6 +25,7 @@ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
setPluginExtensionGetter: jest.fn(), setPluginExtensionGetter: jest.fn(),
getPluginLinkExtensions: jest.fn(), getPluginLinkExtensions: jest.fn(),
useChromeHeaderHeight: jest.fn().mockReturnValue(80),
getBackendSrv: () => { getBackendSrv: () => {
return { return {
get: jest.fn().mockResolvedValue({ dashboard: simpleDashboard, meta: { url: '' } }), get: jest.fn().mockResolvedValue({ dashboard: simpleDashboard, meta: { url: '' } }),

@ -1,4 +1,4 @@
import { css, cx } from '@emotion/css'; import { css } from '@emotion/css';
import { GrafanaTheme2, VariableHide } from '@grafana/data'; import { GrafanaTheme2, VariableHide } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
@ -119,7 +119,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
const { variableControls, refreshPicker, timePicker, hideTimeControls, hideVariableControls, hideLinksControls } = const { variableControls, refreshPicker, timePicker, hideTimeControls, hideVariableControls, hideLinksControls } =
model.useState(); model.useState();
const dashboard = getDashboardSceneFor(model); const dashboard = getDashboardSceneFor(model);
const { links, meta, editPanel } = dashboard.useState(); const { links, editPanel } = dashboard.useState();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const showDebugger = location.search.includes('scene-debugger'); const showDebugger = location.search.includes('scene-debugger');
@ -128,10 +128,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
} }
return ( return (
<div <div data-testid={selectors.pages.Dashboard.Controls} className={styles.controls}>
data-testid={selectors.pages.Dashboard.Controls}
className={cx(styles.controls, meta.isEmbedded && styles.embedded)}
>
<Stack grow={1} wrap={'wrap'}> <Stack grow={1} wrap={'wrap'}>
{!hideVariableControls && variableControls.map((c) => <c.Component model={c} key={c.state.key} />)} {!hideVariableControls && variableControls.map((c) => <c.Component model={c} key={c.state.key} />)}
<Box grow={1} /> <Box grow={1} />
@ -159,18 +156,12 @@ function getStyles(theme: GrafanaTheme2) {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'nowrap', flexWrap: 'nowrap',
position: 'relative', position: 'relative',
background: theme.colors.background.canvas,
zIndex: theme.zIndex.activePanel,
width: '100%', width: '100%',
marginLeft: 'auto', marginLeft: 'auto',
[theme.breakpoints.down('sm')]: { [theme.breakpoints.down('sm')]: {
flexDirection: 'column-reverse', flexDirection: 'column-reverse',
alignItems: 'stretch', alignItems: 'stretch',
}, },
[theme.breakpoints.up('sm')]: {
position: 'sticky',
top: 0,
},
}), }),
embedded: css({ embedded: css({
background: 'unset', background: 'unset',

@ -1,15 +1,15 @@
import { render, screen } from '@testing-library/react'; import { screen } from '@testing-library/react';
import { Provider } from 'react-redux'; import { render } from 'test/test-utils';
import { Router } from 'react-router';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { locationService } from '@grafana/runtime';
import { GrafanaContext } from 'app/core/context/GrafanaContext';
import { configureStore } from 'app/store/configureStore';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
useChromeHeaderHeight: jest.fn(),
}));
describe('DashboardSceneRenderer', () => { describe('DashboardSceneRenderer', () => {
it('should render Not Found notice when dashboard is not found', async () => { it('should render Not Found notice when dashboard is not found', async () => {
const scene = transformSaveModelToScene({ const scene = transformSaveModelToScene({
@ -46,18 +46,7 @@ describe('DashboardSceneRenderer', () => {
}, },
}); });
const store = configureStore({}); render(<scene.Component model={scene} />);
const context = getGrafanaContextMock();
render(
<GrafanaContext.Provider value={context}>
<Provider store={store}>
<Router history={locationService.getHistory()}>
<scene.Component model={scene} />
</Router>
</Provider>
</GrafanaContext.Provider>
);
expect(await screen.findByTestId(selectors.components.EntityNotFound.container)).toBeInTheDocument(); expect(await screen.findByTestId(selectors.components.EntityNotFound.container)).toBeInTheDocument();
}); });

@ -1,12 +1,11 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useMedia } from 'react-use';
import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { useChromeHeaderHeight } from '@grafana/runtime';
import { config } from '@grafana/runtime';
import { SceneComponentProps } from '@grafana/scenes'; import { SceneComponentProps } from '@grafana/scenes';
import { CustomScrollbar, useStyles2, useTheme2 } from '@grafana/ui'; import { useStyles2 } from '@grafana/ui';
import NativeScrollbar from 'app/core/components/NativeScrollbar';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound'; import { EntityNotFound } from 'app/core/components/PageNotFound/EntityNotFound';
import { getNavModel } from 'app/core/selectors/navModel'; import { getNavModel } from 'app/core/selectors/navModel';
@ -18,7 +17,8 @@ import { NavToolbarActions } from './NavToolbarActions';
export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) { export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {
const { controls, overlay, editview, editPanel, isEmpty, meta } = model.useState(); const { controls, overlay, editview, editPanel, isEmpty, meta } = model.useState();
const styles = useStyles2(getStyles); const headerHeight = useChromeHeaderHeight();
const styles = useStyles2(getStyles, headerHeight);
const location = useLocation(); const location = useLocation();
const navIndex = useSelector((state) => state.navIndex); const navIndex = useSelector((state) => state.navIndex);
const pageNav = model.getPageNav(location, navIndex); const pageNav = model.getPageNav(location, navIndex);
@ -59,57 +59,43 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Custom}> <Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Custom}>
{editPanel && <editPanel.Component model={editPanel} />} {editPanel && <editPanel.Component model={editPanel} />}
{!editPanel && ( {!editPanel && (
<div className={cx(styles.pageContainer, hasControls && styles.pageContainerWithControls)}> <NativeScrollbar divId="page-scrollbar" autoHeightMin={'100%'}>
<NavToolbarActions dashboard={model} /> <div className={cx(styles.pageContainer, hasControls && styles.pageContainerWithControls)}>
{controls && ( <NavToolbarActions dashboard={model} />
<div className={styles.controlsWrapper}> {controls && (
<controls.Component model={controls} /> <div className={styles.controlsWrapper}>
</div> <controls.Component model={controls} />
)} </div>
<PanelsContainer )}
// This id is used by the image renderer to scroll through the dashboard
id="page-scrollbar"
className={styles.panelsContainer}
testId={selectors.pages.Dashboard.DashNav.scrollContainer}
>
<div className={cx(styles.canvasContent)}>{body}</div> <div className={cx(styles.canvasContent)}>{body}</div>
</PanelsContainer> </div>
</div> </NativeScrollbar>
)} )}
{overlay && <overlay.Component model={overlay} />} {overlay && <overlay.Component model={overlay} />}
</Page> </Page>
); );
} }
function getStyles(theme: GrafanaTheme2) { function getStyles(theme: GrafanaTheme2, headerHeight: number | undefined) {
return { return {
pageContainer: css( pageContainer: css({
{ display: 'grid',
display: 'grid', gridTemplateAreas: `
gridTemplateAreas: `
"panels"`, "panels"`,
gridTemplateColumns: `1fr`, gridTemplateColumns: `1fr`,
gridTemplateRows: '1fr', gridTemplateRows: '1fr',
height: '100%', flexGrow: 1,
[theme.breakpoints.down('sm')]: { [theme.breakpoints.down('sm')]: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
},
}, },
config.featureToggles.bodyScrolling && { }),
position: 'absolute',
width: '100%',
}
),
pageContainerWithControls: css({ pageContainerWithControls: css({
gridTemplateAreas: ` gridTemplateAreas: `
"controls" "controls"
"panels"`, "panels"`,
gridTemplateRows: 'auto 1fr', gridTemplateRows: 'auto 1fr',
}), }),
panelsContainer: css({
gridArea: 'panels',
}),
controlsWrapper: css({ controlsWrapper: css({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
@ -119,6 +105,13 @@ function getStyles(theme: GrafanaTheme2) {
':empty': { ':empty': {
display: 'none', display: 'none',
}, },
// Make controls sticky on larger screens (> mobile)
[theme.breakpoints.up('md')]: {
position: 'sticky',
zIndex: theme.zIndex.activePanel,
background: theme.colors.background.canvas,
top: headerHeight,
},
}), }),
canvasContent: css({ canvasContent: css({
label: 'canvas-content', label: 'canvas-content',
@ -126,7 +119,9 @@ function getStyles(theme: GrafanaTheme2) {
flexDirection: 'column', flexDirection: 'column',
padding: theme.spacing(0, 2), padding: theme.spacing(0, 2),
flexBasis: '100%', flexBasis: '100%',
gridArea: 'panels',
flexGrow: 1, flexGrow: 1,
minWidth: 0,
}), }),
body: css({ body: css({
label: 'body', label: 'body',
@ -141,34 +136,3 @@ function getStyles(theme: GrafanaTheme2) {
}), }),
}; };
} }
interface PanelsContainerProps {
id: string;
children: React.ReactNode;
className?: string;
testId?: string;
}
/**
* Removes the scrollbar on mobile and uses a custom scrollbar on desktop
*/
const PanelsContainer = ({ id, children, className, testId }: PanelsContainerProps) => {
const theme = useTheme2();
const isMobile = useMedia(`(max-width: ${theme.breakpoints.values.sm}px)`);
const styles = useStyles2(() => ({
nonScrollable: css({
height: '100%',
display: 'flex',
flexDirection: 'column',
}),
}));
return isMobile ? (
<div id={id} className={cx(className, styles.nonScrollable)} data-testid={testId}>
{children}
</div>
) : (
<CustomScrollbar divId={id} autoHeightMin={'100%'} className={className} testId={testId}>
{children}
</CustomScrollbar>
);
};

@ -1,7 +1,8 @@
import { act, cleanup, waitFor } from '@testing-library/react'; import { act, cleanup, waitFor } from '@testing-library/react';
import userEvents from '@testing-library/user-event'; import userEvents from '@testing-library/user-event';
import { config, locationService } from '@grafana/runtime'; import { getPanelPlugin } from '@grafana/data/test/__mocks__/pluginMocks';
import { config, locationService, setPluginImportUtils } from '@grafana/runtime';
import { sceneGraph } from '@grafana/scenes'; import { sceneGraph } from '@grafana/scenes';
import { getDashboardAPI, setDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; import { getDashboardAPI, setDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene'; import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
@ -62,16 +63,30 @@ import { getClosestScopesFacade } from './utils';
jest.mock('@grafana/runtime', () => ({ jest.mock('@grafana/runtime', () => ({
__esModule: true, __esModule: true,
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
useChromeHeaderHeight: jest.fn(),
getBackendSrv: () => ({ getBackendSrv: () => ({
get: getMock, get: getMock,
}), }),
usePluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }), usePluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
})); }));
const panelPlugin = getPanelPlugin({
id: 'table',
skipDataQuery: true,
});
config.panels['table'] = panelPlugin.meta;
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(panelPlugin),
getPanelPluginFromCache: (id: string) => undefined,
});
describe('Scopes', () => { describe('Scopes', () => {
describe('Feature flag off', () => { describe('Feature flag off', () => {
beforeAll(() => { beforeAll(() => {
config.featureToggles.scopeFilters = false; config.featureToggles.scopeFilters = false;
config.featureToggles.groupByVariable = true;
initializeScopes(); initializeScopes();
}); });
@ -88,6 +103,7 @@ describe('Scopes', () => {
beforeAll(() => { beforeAll(() => {
config.featureToggles.scopeFilters = true; config.featureToggles.scopeFilters = true;
config.featureToggles.groupByVariable = true;
}); });
beforeEach(() => { beforeEach(() => {

@ -3739,9 +3739,9 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft linkType: soft
"@grafana/scenes@npm:5.7.4": "@grafana/scenes@npm:^5.8.0":
version: 5.7.4 version: 5.8.0
resolution: "@grafana/scenes@npm:5.7.4" resolution: "@grafana/scenes@npm:5.8.0"
dependencies: dependencies:
"@grafana/e2e-selectors": "npm:^11.0.0" "@grafana/e2e-selectors": "npm:^11.0.0"
"@leeoniya/ufuzzy": "npm:^1.0.14" "@leeoniya/ufuzzy": "npm:^1.0.14"
@ -3756,7 +3756,7 @@ __metadata:
"@grafana/ui": ">=10.4" "@grafana/ui": ">=10.4"
react: ^18.0.0 react: ^18.0.0
react-dom: ^18.0.0 react-dom: ^18.0.0
checksum: 10/e78f0d215e8a7a591689ce0b4672732bbf22ab38964e908cbf328125c855bcbf9569945dc2731a114e1a7244d2b7c7da98a1df2ac1ec4883c8201e0ab68de75f checksum: 10/1c550dd5256371de0849ae64d167c4a9dbd99be0c03f15116ac213d3a9e2ec4b5248331086ca9d6616b5ad8f2be5be503772ac38a853e32da57f485ef3addb41
languageName: node languageName: node
linkType: hard linkType: hard
@ -18163,7 +18163,7 @@ __metadata:
"@grafana/prometheus": "workspace:*" "@grafana/prometheus": "workspace:*"
"@grafana/runtime": "workspace:*" "@grafana/runtime": "workspace:*"
"@grafana/saga-icons": "workspace:*" "@grafana/saga-icons": "workspace:*"
"@grafana/scenes": "npm:5.7.4" "@grafana/scenes": "npm:^5.8.0"
"@grafana/schema": "workspace:*" "@grafana/schema": "workspace:*"
"@grafana/sql": "workspace:*" "@grafana/sql": "workspace:*"
"@grafana/tsconfig": "npm:^2.0.0" "@grafana/tsconfig": "npm:^2.0.0"

Loading…
Cancel
Save