PublicDashboards: A unique page for public dashboards (#60744)

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
juanicabanas 2023-01-10 14:50:37 -03:00 committed by GitHub
parent d19d8c6625
commit 2505f112f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 334 additions and 48 deletions

View File

@ -58,6 +58,7 @@ export const PageToolbar: FC<Props> = React.memo(
styles.toolbar,
{
['page-toolbar--fullscreen']: isFullscreen,
[styles.noPageIcon]: !pageIcon,
},
className
);
@ -160,6 +161,15 @@ const getStyles = (theme: GrafanaTheme2) => {
gap: ${theme.spacing(2)};
justify-content: space-between;
padding: ${theme.spacing(1.5, 2)};
${theme.breakpoints.down('md')} {
padding-left: 53px;
}
`,
noPageIcon: css`
${theme.breakpoints.down('md')} {
padding-left: ${theme.spacing(2)};
}
`,
leftWrapper: css`
display: flex;

View File

@ -44,6 +44,7 @@ func (api *Api) ViewPublicDashboard(c *models.ReqContext) response.Response {
PublicDashboardAccessToken: pubdash.AccessToken,
PublicDashboardUID: pubdash.Uid,
}
dash.Data.Get("timepicker").Set("hidden", !pubdash.TimeSelectionEnabled)
dto := dtos.DashboardFullWithMeta{Meta: meta, Dashboard: dash.Data}

View File

@ -20,7 +20,7 @@ export function AppChrome({ children }: Props) {
const { chrome } = useGrafana();
const state = chrome.useState();
if (!config.featureToggles.topnav || config.isPublicDashboardView) {
if (!config.featureToggles.topnav) {
return <main className="main-view">{children}</main>;
}

View File

@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, colorManipulator } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
export interface PublicDashboardFooterCfg {
@ -38,14 +38,10 @@ export let getPublicDashboardFooterConfig = (): PublicDashboardFooterCfg => ({
const getStyles = (theme: GrafanaTheme2) => ({
footer: css`
position: absolute;
display: flex;
justify-content: end;
height: 30px;
bottom: 0;
width: 100%;
background-color: ${colorManipulator.alpha(theme.colors.background.canvas, 0.7)};
text-align: right;
font-size: ${theme.typography.body.fontSize};
z-index: ${theme.zIndex.navbarFixed};
padding: ${theme.spacing(0, 1, 0, 1)};
`,
logoText: css`
margin-right: ${theme.spacing(1)};

View File

@ -320,20 +320,4 @@ describe('DashboardPage', () => {
expect(screen.queryAllByLabelText(selectors.pages.Dashboard.SubMenu.submenu)).toHaveLength(0);
});
});
dashboardPageScenario('When dashboard is public', (ctx) => {
ctx.setup(() => {
locationService.partial({ kiosk: false });
ctx.mount({
queryParams: {},
dashboard: getTestDashboard(),
});
ctx.rerender({ dashboard: ctx.dashboard, isPublic: true });
});
it('should not render page toolbar and submenu', () => {
expect(screen.queryAllByTestId(selectors.pages.Dashboard.DashNav.navV2)).toHaveLength(0);
expect(screen.queryAllByLabelText(selectors.pages.Dashboard.SubMenu.submenu)).toHaveLength(0);
});
});
});

View File

@ -28,7 +28,6 @@ import { DashboardPrompt } from '../components/DashboardPrompt/DashboardPrompt';
import { DashboardSettings } from '../components/DashboardSettings';
import { PanelInspector } from '../components/Inspector/PanelInspector';
import { PanelEditor } from '../components/PanelEditor/PanelEditor';
import { PublicDashboardFooter } from '../components/PublicDashboardFooter/PublicDashboardsFooter';
import { SubMenu } from '../components/SubMenu/SubMenu';
import { DashboardGrid } from '../dashgrid/DashboardGrid';
import { liveTimer } from '../dashgrid/liveTimer';
@ -75,12 +74,7 @@ const mapDispatchToProps = {
const connector = connect(mapStateToProps, mapDispatchToProps);
type OwnProps = {
isPublic?: boolean;
};
export type Props = OwnProps &
Themeable2 &
export type Props = Themeable2 &
GrafanaRouteComponentProps<DashboardPageRouteParams, DashboardPageRouteSearchParams> &
ConnectedProps<typeof connector>;
@ -129,7 +123,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
}
initDashboard() {
const { dashboard, isPublic, match, queryParams } = this.props;
const { dashboard, match, queryParams } = this.props;
if (dashboard) {
this.closeDashboard();
@ -142,7 +136,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
urlFolderUid: queryParams.folderUid,
panelType: queryParams.panelType,
routeName: this.props.route.routeName,
fixUrl: !isPublic,
fixUrl: true,
accessToken: match.params.accessToken,
keybindingSrv: this.context.keybindings,
});
@ -341,9 +335,9 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
}
render() {
const { dashboard, initError, queryParams, isPublic } = this.props;
const { dashboard, initError, queryParams } = this.props;
const { editPanel, viewPanel, updateScrollTop, pageNav, sectionNav } = this.state;
const kioskMode = !isPublic ? getKioskMode(this.props.queryParams) : KioskMode.Full;
const kioskMode = getKioskMode(this.props.queryParams);
if (!dashboard || !pageNav || !sectionNav) {
return <DashboardLoading initPhase={this.props.initPhase} />;
@ -418,10 +412,6 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
sectionNav={sectionNav}
/>
)}
{
// TODO: assess if there are other places where we may want a footer, which may reveal a better place to add this
isPublic && <PublicDashboardFooter />
}
</>
);
}

View File

@ -0,0 +1,207 @@
import { render, RenderResult, screen } from '@testing-library/react';
import React from 'react';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { useEffectOnce } from 'react-use';
import { AutoSizerProps } from 'react-virtualized-auto-sizer';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { locationService } from '@grafana/runtime';
import { Dashboard, DashboardCursorSync } from '@grafana/schema/src';
import { GrafanaContext } from 'app/core/context/GrafanaContext';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { DashboardInitPhase, DashboardMeta, DashboardRoutes } from 'app/types';
import * as appTypes from 'app/types';
import { SafeDynamicImport } from '../../../core/components/DynamicImports/SafeDynamicImport';
import { configureStore } from '../../../store/configureStore';
import { Props as LazyLoaderProps } from '../dashgrid/LazyLoader';
import { DashboardModel } from '../state';
import { initDashboard } from '../state/initDashboard';
import PublicDashboardPage, { Props } from './PublicDashboardPage';
jest.mock('app/features/dashboard/dashgrid/LazyLoader', () => {
const LazyLoader = ({ children, onLoad }: Pick<LazyLoaderProps, 'children' | 'onLoad'>) => {
useEffectOnce(() => {
onLoad?.();
});
return <>{typeof children === 'function' ? children({ isInView: true }) : children}</>;
};
return { LazyLoader };
});
jest.mock('react-virtualized-auto-sizer', () => {
// // // The size of the children need to be small enough to be outside the view.
// // // So it does not trigger the query to be run by the PanelQueryRunner.
return ({ children }: AutoSizerProps) => children({ height: 1, width: 1 });
});
jest.mock('app/features/dashboard/state/initDashboard', () => ({
...jest.requireActual('app/features/dashboard/state/initDashboard'),
initDashboard: jest.fn(),
}));
jest.mock('app/types', () => ({
...jest.requireActual('app/types'),
useDispatch: () => jest.fn(),
}));
const renderWithProvider = ({
props,
initialState,
}: {
props: Props;
initialState?: Partial<appTypes.StoreState>;
}): RenderResult => {
const context = getGrafanaContextMock();
const store = configureStore(initialState);
return render(
<GrafanaContext.Provider value={context}>
<Provider store={store}>
<Router history={locationService.getHistory()}>
<PublicDashboardPage {...props} />
</Router>
</Provider>
</GrafanaContext.Provider>
);
};
interface ScenarioContext {
mount: () => void;
rerender: ({
propOverrides,
newState,
}: {
propOverrides?: Partial<Props>;
newState?: Partial<appTypes.StoreState>;
}) => void;
setup: (fn: () => void) => void;
}
const getTestDashboard = (overrides?: Partial<Dashboard>, metaOverrides?: Partial<DashboardMeta>): DashboardModel => {
const data: Dashboard = Object.assign(
{
title: 'My dashboard',
revision: 1,
editable: false,
graphTooltip: DashboardCursorSync.Off,
schemaVersion: 1,
style: 'dark',
panels: [
{
id: 1,
type: 'timeseries',
title: 'My panel title',
gridPos: { x: 0, y: 0, w: 1, h: 1 },
},
],
},
overrides
);
return new DashboardModel(data, metaOverrides);
};
function dashboardPageScenario(description: string, scenarioFn: (ctx: ScenarioContext) => void) {
describe(description, () => {
let setupFn: () => void;
const ctx: ScenarioContext = {
setup: (fn) => {
setupFn = fn;
},
mount: () => {
const props: Props = {
...getRouteComponentProps({
match: { params: { accessToken: 'an-access-token' }, isExact: true, url: '', path: '' },
route: {
routeName: DashboardRoutes.Public,
path: '/public-dashboards/:accessToken',
component: SafeDynamicImport(
() =>
import(
/* webpackChunkName: "PublicDashboardPage"*/ 'app/features/dashboard/containers/PublicDashboardPage'
)
),
},
}),
};
const { rerender } = renderWithProvider({ props });
ctx.rerender = ({
propsOverride,
newState,
}: {
propsOverride?: Partial<Props>;
newState?: Partial<appTypes.StoreState>;
}) => {
Object.assign(props, propsOverride);
const context = getGrafanaContextMock();
const store = configureStore(newState);
rerender(
<GrafanaContext.Provider value={context}>
<Provider store={store}>
<Router history={locationService.getHistory()}>
<PublicDashboardPage {...props} />
</Router>
</Provider>
</GrafanaContext.Provider>
);
};
},
rerender: () => {},
};
beforeEach(() => {
setupFn();
});
scenarioFn(ctx);
});
}
describe('PublicDashboardPage', () => {
dashboardPageScenario('Given initial state', (ctx) => {
ctx.setup(() => {
ctx.mount();
});
it('Should call initDashboard on mount', () => {
expect(initDashboard).toBeCalledWith({
fixUrl: false,
accessToken: 'an-access-token',
routeName: 'public-dashboard',
keybindingSrv: expect.anything(),
});
});
});
dashboardPageScenario('Given a simple dashboard', (ctx) => {
ctx.setup(() => {
ctx.mount();
ctx.rerender({
newState: {
dashboard: {
getModel: getTestDashboard,
initError: null,
initPhase: DashboardInitPhase.Completed,
permissions: [],
},
},
});
});
it('Should render panels', () => {
expect(screen.getByText('My panel title')).toBeInTheDocument();
});
it('Should update title', () => {
expect(document.title).toBe('My dashboard - Grafana');
});
});
});

View File

@ -1,13 +1,112 @@
import React from 'react';
import { css } from '@emotion/css';
import React, { useEffect } from 'react';
import { usePrevious } from 'react-use';
import { GrafanaRouteComponentProps } from '../../../core/navigation/types';
import { GrafanaTheme2, PageLayoutType, TimeZone } from '@grafana/data';
import { PageToolbar, useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { updateTimeZoneForSession } from 'app/features/profile/state/reducers';
import { useSelector, useDispatch } from 'app/types';
import DashboardPage, { DashboardPageRouteParams, DashboardPageRouteSearchParams } from './DashboardPage';
import { DashNavTimeControls } from '../components/DashNav/DashNavTimeControls';
import { DashboardFailed } from '../components/DashboardLoading/DashboardFailed';
import { DashboardLoading } from '../components/DashboardLoading/DashboardLoading';
import { PublicDashboardFooter } from '../components/PublicDashboardFooter/PublicDashboardsFooter';
import { DashboardGrid } from '../dashgrid/DashboardGrid';
import { getTimeSrv } from '../services/TimeSrv';
import { DashboardModel } from '../state';
import { initDashboard } from '../state/initDashboard';
export type Props = GrafanaRouteComponentProps<DashboardPageRouteParams, DashboardPageRouteSearchParams>;
interface PublicDashboardPageRouteParams {
accessToken?: string;
}
const PublicDashboardPage = (props: Props) => {
return <DashboardPage isPublic {...props} />;
interface PublicDashboardPageRouteSearchParams {
from?: string;
to?: string;
refresh?: string;
}
export type Props = GrafanaRouteComponentProps<PublicDashboardPageRouteParams, PublicDashboardPageRouteSearchParams>;
const Toolbar = ({ dashboard }: { dashboard: DashboardModel }) => {
const dispatch = useDispatch();
const onChangeTimeZone = (timeZone: TimeZone) => {
dispatch(updateTimeZoneForSession(timeZone));
};
return (
<PageToolbar title={dashboard.title} buttonOverflowAlignment="right">
{!dashboard.timepicker.hidden && (
<DashNavTimeControls dashboard={dashboard} onChangeTimeZone={onChangeTimeZone} />
)}
</PageToolbar>
);
};
const PublicDashboardPage = (props: Props) => {
const { match, route, location } = props;
const dispatch = useDispatch();
const context = useGrafana();
const prevProps = usePrevious(props);
const styles = useStyles2(getStyles);
const dashboardState = useSelector((store) => store.dashboard);
const dashboard = dashboardState.getModel();
useEffect(() => {
dispatch(
initDashboard({
routeName: route.routeName,
fixUrl: false,
accessToken: match.params.accessToken,
keybindingSrv: context.keybindings,
})
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (prevProps?.location.search !== location.search) {
const prevUrlParams = prevProps?.queryParams;
const urlParams = props.queryParams;
if (urlParams?.from !== prevUrlParams?.from || urlParams?.to !== prevUrlParams?.to) {
getTimeSrv().updateTimeRangeFromUrl();
}
if (!prevUrlParams?.refresh && urlParams?.refresh) {
getTimeSrv().setAutoRefresh(urlParams.refresh);
}
}
}, [prevProps, location.search, props.queryParams]);
if (!dashboard) {
return <DashboardLoading initPhase={dashboardState.initPhase} />;
}
return (
<Page
pageNav={{ text: dashboard.title }}
layout={PageLayoutType.Custom}
toolbar={<Toolbar dashboard={dashboard} />}
>
{dashboardState.initError && <DashboardFailed />}
<div className={styles.gridContainer}>
<DashboardGrid dashboard={dashboard} isEditable={false} viewPanel={null} editPanel={null} />
</div>
<PublicDashboardFooter />
</Page>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
gridContainer: css({
padding: theme.spacing(0, 2, 2, 2),
overflow: 'auto',
}),
});
export default PublicDashboardPage;

View File

@ -21,6 +21,7 @@ export const getPublicDashboardRoutes = (): RouteDescriptor[] => {
path: '/public-dashboards/:accessToken',
pageClass: 'page-dashboard',
routeName: DashboardRoutes.Public,
chromeless: true,
component: SafeDynamicImport(
() =>
import(

View File

@ -57,8 +57,6 @@
@include media-breakpoint-down(sm) {
nav.page-toolbar {
padding-left: 53px;
&--fullscreen {
padding-left: $space-md;
}