DashboardScene: Support remember scroll position when coming back from view panel, panel edit and settings (#92185)

* DashboardScene: Support remember scroll position when coming back from view panel, panel edit and settings

* remove unused state prop

* Update

* Fixes

* Update e2e
This commit is contained in:
Torkel Ödegaard 2024-08-21 16:05:07 +02:00 committed by GitHub
parent 81ce3c92d5
commit 801f2ba728
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 97 additions and 63 deletions

View File

@ -24,9 +24,7 @@ describe('Dashboards', () => {
e2e.components.Panels.Panel.menuItems('Edit').click(); e2e.components.Panels.Panel.menuItems('Edit').click();
e2e.components.NavToolbar.editDashboard.backToDashboardButton().click(); e2e.components.NavToolbar.editDashboard.backToDashboardButton().click();
// And the last panel should still be visible! // The last panel should still be visible!
// 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');
}); });
}); });

View File

@ -2,28 +2,35 @@ import { css, cx } from '@emotion/css';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { CustomScrollbar, useStyles2 } from '@grafana/ui'; import { useStyles2 } from '@grafana/ui';
type Props = Parameters<typeof CustomScrollbar>[0]; export interface Props {
children: React.ReactNode;
onSetScrollRef?: (ref: ScrollRefElement) => void;
divId?: string;
}
export interface ScrollRefElement {
scrollTop: number;
scrollTo: (x: number, y: number) => void;
}
// Shim to provide API-compatibility for Page's scroll-related props // Shim to provide API-compatibility for Page's scroll-related props
// when bodyScrolling is enabled, this is a no-op // when bodyScrolling is enabled, this is a no-op
// TODO remove this shim completely when bodyScrolling is enabled // TODO remove this shim completely when bodyScrolling is enabled
export default function NativeScrollbar({ children, scrollRefCallback, scrollTop, divId }: Props) { export default function NativeScrollbar({ children, onSetScrollRef, divId }: Props) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!config.featureToggles.bodyScrolling && ref.current && scrollRefCallback) { if (config.featureToggles.bodyScrolling && onSetScrollRef) {
scrollRefCallback(ref.current); onSetScrollRef(new WindowScrollElement());
} }
}, [ref, scrollRefCallback]);
useEffect(() => { if (!config.featureToggles.bodyScrolling && ref.current && onSetScrollRef) {
if (!config.featureToggles.bodyScrolling && ref.current && scrollTop != null) { onSetScrollRef(ref.current);
ref.current?.scrollTo(0, scrollTop);
} }
}, [scrollTop]); }, [ref, onSetScrollRef]);
return config.featureToggles.bodyScrolling ? ( return config.featureToggles.bodyScrolling ? (
children children
@ -35,6 +42,16 @@ export default function NativeScrollbar({ children, scrollRefCallback, scrollTop
); );
} }
class WindowScrollElement {
public get scrollTop() {
return window.scrollY;
}
public scrollTo(x: number, y: number) {
window.scrollTo(x, y);
}
}
function getStyles() { function getStyles() {
return { return {
nativeScrollbars: css({ nativeScrollbars: css({

View File

@ -27,8 +27,7 @@ export const Page: PageType = ({
className, className,
info, info,
layout = PageLayoutType.Standard, layout = PageLayoutType.Standard,
scrollTop, onSetScrollRef,
scrollRef,
...otherProps ...otherProps
}) => { }) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
@ -57,9 +56,7 @@ export const Page: PageType = ({
<NativeScrollbar <NativeScrollbar
// This id is used by the image renderer to scroll through the dashboard // This id is used by the image renderer to scroll through the dashboard
divId="page-scrollbar" divId="page-scrollbar"
autoHeightMin={'100%'} onSetScrollRef={onSetScrollRef}
scrollTop={scrollTop}
scrollRefCallback={scrollRef}
> >
<div className={styles.pageInner}> <div className={styles.pageInner}>
{pageHeaderNav && ( {pageHeaderNav && (
@ -82,9 +79,7 @@ export const Page: PageType = ({
<NativeScrollbar <NativeScrollbar
// This id is used by the image renderer to scroll through the dashboard // This id is used by the image renderer to scroll through the dashboard
divId="page-scrollbar" divId="page-scrollbar"
autoHeightMin={'100%'} onSetScrollRef={onSetScrollRef}
scrollTop={scrollTop}
scrollRefCallback={scrollRef}
> >
<div className={styles.canvasContent}>{children}</div> <div className={styles.canvasContent}>{children}</div>
</NativeScrollbar> </NativeScrollbar>

View File

@ -1,8 +1,10 @@
import { FC, HTMLAttributes, RefCallback } from 'react'; import { FC, HTMLAttributes } from 'react';
import * as React from 'react'; import * as React from 'react';
import { NavModel, NavModelItem, PageLayoutType } from '@grafana/data'; import { NavModel, NavModelItem, PageLayoutType } from '@grafana/data';
import { ScrollRefElement } from '../NativeScrollbar';
import { PageContents } from './PageContents'; import { PageContents } from './PageContents';
export interface PageProps extends HTMLAttributes<HTMLDivElement> { export interface PageProps extends HTMLAttributes<HTMLDivElement> {
@ -22,15 +24,11 @@ export interface PageProps extends HTMLAttributes<HTMLDivElement> {
/** Control the page layout. */ /** Control the page layout. */
layout?: PageLayoutType; layout?: PageLayoutType;
/** /**
* TODO: Not sure we should deprecated it given the sidecar project?
* @deprecated this will be removed when bodyScrolling is enabled by default * @deprecated this will be removed when bodyScrolling is enabled by default
* Can be used to get the scroll container element to access scroll position * Can be used to get the scroll container element to access scroll position
* */ * */
scrollRef?: RefCallback<HTMLDivElement>; onSetScrollRef?: (ref: ScrollRefElement) => void;
/**
* @deprecated this will be removed when bodyScrolling is enabled by default
* Can be used to update the current scroll position
* */
scrollTop?: number;
} }
export interface PageInfoItem { export interface PageInfoItem {

View File

@ -27,6 +27,7 @@ import {
} from '@grafana/scenes'; } from '@grafana/scenes';
import { Dashboard, DashboardLink, LibraryPanel } from '@grafana/schema'; import { Dashboard, DashboardLink, LibraryPanel } from '@grafana/schema';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { ScrollRefElement } from 'app/core/components/NativeScrollbar';
import { LS_PANEL_COPY_KEY } from 'app/core/constants'; import { LS_PANEL_COPY_KEY } from 'app/core/constants';
import { getNavModel } from 'app/core/selectors/navModel'; import { getNavModel } from 'app/core/selectors/navModel';
import store from 'app/core/store'; import store from 'app/core/store';
@ -167,6 +168,11 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
* A reference to the scopes facade * A reference to the scopes facade
*/ */
private _scopesFacade: ScopesFacade | null; private _scopesFacade: ScopesFacade | null;
/**
* Remember scroll position when going into panel edit
*/
private _scrollRef?: ScrollRefElement;
private _prevScrollPos?: number;
public constructor(state: Partial<DashboardSceneState>) { public constructor(state: Partial<DashboardSceneState>) {
super({ super({
@ -925,6 +931,22 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
} }
}); });
} }
public onSetScrollRef = (scrollElement: ScrollRefElement): void => {
this._scrollRef = scrollElement;
};
public rememberScrollPos() {
this._prevScrollPos = this._scrollRef?.scrollTop;
}
public restoreScrollPos() {
if (this._prevScrollPos !== undefined) {
setTimeout(() => {
this._scrollRef?.scrollTo(0, this._prevScrollPos!);
}, 50);
}
}
} }
export class DashboardVariableDependency implements SceneVariableDependencyConfigLike { export class DashboardVariableDependency implements SceneVariableDependencyConfigLike {

View File

@ -1,4 +1,5 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
@ -16,7 +17,7 @@ import { DashboardScene } from './DashboardScene';
import { NavToolbarActions } from './NavToolbarActions'; 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, viewPanelScene } = model.useState();
const headerHeight = useChromeHeaderHeight(); const headerHeight = useChromeHeaderHeight();
const styles = useStyles2(getStyles, headerHeight); const styles = useStyles2(getStyles, headerHeight);
const location = useLocation(); const location = useLocation();
@ -25,6 +26,21 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
const bodyToRender = model.getBodyToRender(); const bodyToRender = model.getBodyToRender();
const navModel = getNavModel(navIndex, 'dashboards/browse'); const navModel = getNavModel(navIndex, 'dashboards/browse');
const hasControls = controls?.hasControls(); const hasControls = controls?.hasControls();
const isSettingsOpen = editview !== undefined;
// Remember scroll pos when going into view panel, edit panel or settings
useMemo(() => {
if (viewPanelScene || isSettingsOpen || editPanel) {
model.rememberScrollPos();
}
}, [isSettingsOpen, editPanel, viewPanelScene, model]);
// Restore scroll pos when coming back
useEffect(() => {
if (!viewPanelScene && !isSettingsOpen && !editPanel) {
model.restoreScrollPos();
}
}, [isSettingsOpen, editPanel, viewPanelScene, model]);
if (editview) { if (editview) {
return ( return (
@ -54,12 +70,11 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
} else if (isEmpty) { } else if (isEmpty) {
body = [emptyState, withPanels]; body = [emptyState, withPanels];
} }
return ( return (
<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 && (
<NativeScrollbar divId="page-scrollbar" autoHeightMin={'100%'}> <NativeScrollbar divId="page-scrollbar" onSetScrollRef={model.onSetScrollRef}>
<div className={cx(styles.pageContainer, hasControls && styles.pageContainerWithControls)}> <div className={cx(styles.pageContainer, hasControls && styles.pageContainerWithControls)}>
<NavToolbarActions dashboard={model} /> <NavToolbarActions dashboard={model} />
{controls && ( {controls && (

View File

@ -7,6 +7,7 @@ import { selectors } from '@grafana/e2e-selectors';
import { config, locationService } from '@grafana/runtime'; import { config, locationService } from '@grafana/runtime';
import { Themeable2, withTheme2 } from '@grafana/ui'; import { Themeable2, withTheme2 } from '@grafana/ui';
import { notifyApp } from 'app/core/actions'; import { notifyApp } from 'app/core/actions';
import { ScrollRefElement } 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 { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaContext'; import { GrafanaContext, GrafanaContextType } from 'app/core/context/GrafanaContext';
@ -77,7 +78,7 @@ export interface State {
showLoadingState: boolean; showLoadingState: boolean;
panelNotFound: boolean; panelNotFound: boolean;
editPanelAccessDenied: boolean; editPanelAccessDenied: boolean;
scrollElement?: HTMLDivElement; scrollElement?: ScrollRefElement;
pageNav?: NavModelItem; pageNav?: NavModelItem;
sectionNav?: NavModel; sectionNav?: NavModel;
} }
@ -234,11 +235,9 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
locationService.partial({ editPanel: null, viewPanel: null }); locationService.partial({ editPanel: null, viewPanel: null });
} }
if (config.featureToggles.bodyScrolling) { // Update window scroll position
// Update window scroll position if (this.state.updateScrollTop !== undefined && this.state.updateScrollTop !== prevState.updateScrollTop) {
if (this.state.updateScrollTop !== undefined && this.state.updateScrollTop !== prevState.updateScrollTop) { this.state.scrollElement?.scrollTo(0, this.state.updateScrollTop);
window.scrollTo(0, this.state.updateScrollTop);
}
} }
} }
@ -263,19 +262,17 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
const updatedState = { ...state }; const updatedState = { ...state };
if (config.featureToggles.bodyScrolling) { // Entering settings view
// Entering settings view if (!state.editView && urlEditView) {
if (!state.editView && urlEditView) { updatedState.editView = urlEditView;
updatedState.editView = urlEditView; updatedState.rememberScrollTop = state.scrollElement?.scrollTop;
updatedState.rememberScrollTop = window.scrollY; updatedState.updateScrollTop = 0;
updatedState.updateScrollTop = 0; }
}
// Leaving settings view // Leaving settings view
else if (state.editView && !urlEditView) { else if (state.editView && !urlEditView) {
updatedState.updateScrollTop = state.rememberScrollTop; updatedState.updateScrollTop = state.rememberScrollTop;
updatedState.editView = null; updatedState.editView = null;
}
} }
// Entering edit mode // Entering edit mode
@ -284,12 +281,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
if (panel) { if (panel) {
if (dashboard.canEditPanel(panel)) { if (dashboard.canEditPanel(panel)) {
updatedState.editPanel = panel; updatedState.editPanel = panel;
updatedState.rememberScrollTop = config.featureToggles.bodyScrolling updatedState.rememberScrollTop = state.scrollElement?.scrollTop;
? window.scrollY
: state.scrollElement?.scrollTop;
if (config.featureToggles.bodyScrolling) {
updatedState.updateScrollTop = 0;
}
} else { } else {
updatedState.editPanelAccessDenied = true; updatedState.editPanelAccessDenied = true;
} }
@ -311,9 +303,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
// Should move this state out of dashboard in the future // Should move this state out of dashboard in the future
dashboard.initViewPanel(panel); dashboard.initViewPanel(panel);
updatedState.viewPanel = panel; updatedState.viewPanel = panel;
updatedState.rememberScrollTop = config.featureToggles.bodyScrolling updatedState.rememberScrollTop = state.scrollElement?.scrollTop;
? window.scrollY
: state.scrollElement?.scrollTop;
updatedState.updateScrollTop = 0; updatedState.updateScrollTop = 0;
} else { } else {
updatedState.panelNotFound = true; updatedState.panelNotFound = true;
@ -337,7 +327,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
return updateStatePageNavFromProps(props, updatedState); return updateStatePageNavFromProps(props, updatedState);
} }
setScrollRef = (scrollElement: HTMLDivElement): void => { setScrollRef = (scrollElement: ScrollRefElement): void => {
this.setState({ scrollElement }); this.setState({ scrollElement });
}; };
@ -366,7 +356,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
render() { render() {
const { dashboard, initError, queryParams, theme } = this.props; const { dashboard, initError, queryParams, theme } = this.props;
const { editPanel, viewPanel, updateScrollTop, pageNav, sectionNav } = this.state; const { editPanel, viewPanel, pageNav, sectionNav } = this.state;
const kioskMode = getKioskMode(this.props.queryParams); const kioskMode = getKioskMode(this.props.queryParams);
const styles = getStyles(theme); const styles = getStyles(theme);
@ -443,8 +433,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
pageNav={pageNav} pageNav={pageNav}
layout={PageLayoutType.Canvas} layout={PageLayoutType.Canvas}
className={pageClassName} className={pageClassName}
scrollRef={this.setScrollRef} onSetScrollRef={this.setScrollRef}
scrollTop={updateScrollTop}
> >
{showToolbar && ( {showToolbar && (
<header data-testid={selectors.pages.Dashboard.DashNav.navV2}> <header data-testid={selectors.pages.Dashboard.DashNav.navV2}>