From 2505f112f5e140e7ba45be82e27ecfc977a5aeea Mon Sep 17 00:00:00 2001 From: juanicabanas Date: Tue, 10 Jan 2023 14:50:37 -0300 Subject: [PATCH] PublicDashboards: A unique page for public dashboards (#60744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Torkel Ödegaard --- .../src/components/PageLayout/PageToolbar.tsx | 10 + pkg/services/publicdashboards/api/query.go | 1 + .../core/components/AppChrome/AppChrome.tsx | 2 +- .../PublicDashboardsFooter.tsx | 12 +- .../containers/DashboardPage.test.tsx | 16 -- .../dashboard/containers/DashboardPage.tsx | 20 +- .../containers/PublicDashboardPage.test.tsx | 207 ++++++++++++++++++ .../containers/PublicDashboardPage.tsx | 111 +++++++++- public/app/features/dashboard/routes.ts | 1 + public/sass/components/_view_states.scss | 2 - 10 files changed, 334 insertions(+), 48 deletions(-) create mode 100644 public/app/features/dashboard/containers/PublicDashboardPage.test.tsx diff --git a/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx b/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx index 73dbb044885..66db32d3561 100644 --- a/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx +++ b/packages/grafana-ui/src/components/PageLayout/PageToolbar.tsx @@ -58,6 +58,7 @@ export const PageToolbar: FC = 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; diff --git a/pkg/services/publicdashboards/api/query.go b/pkg/services/publicdashboards/api/query.go index 582bec042db..87bbb1355de 100644 --- a/pkg/services/publicdashboards/api/query.go +++ b/pkg/services/publicdashboards/api/query.go @@ -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} diff --git a/public/app/core/components/AppChrome/AppChrome.tsx b/public/app/core/components/AppChrome/AppChrome.tsx index 8acdcfce572..aefa8d54f21 100644 --- a/public/app/core/components/AppChrome/AppChrome.tsx +++ b/public/app/core/components/AppChrome/AppChrome.tsx @@ -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
{children}
; } diff --git a/public/app/features/dashboard/components/PublicDashboardFooter/PublicDashboardsFooter.tsx b/public/app/features/dashboard/components/PublicDashboardFooter/PublicDashboardsFooter.tsx index 4559dae798e..69b004a959f 100644 --- a/public/app/features/dashboard/components/PublicDashboardFooter/PublicDashboardsFooter.tsx +++ b/public/app/features/dashboard/components/PublicDashboardFooter/PublicDashboardsFooter.tsx @@ -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)}; diff --git a/public/app/features/dashboard/containers/DashboardPage.test.tsx b/public/app/features/dashboard/containers/DashboardPage.test.tsx index 5dd77df63b7..32a0e5f9bc5 100644 --- a/public/app/features/dashboard/containers/DashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.test.tsx @@ -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); - }); - }); }); diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 717f79579de..50f0ea674c7 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -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 & ConnectedProps; @@ -129,7 +123,7 @@ export class UnthemedDashboardPage extends PureComponent { } 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 { 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 { } 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 ; @@ -418,10 +412,6 @@ export class UnthemedDashboardPage extends PureComponent { 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 && - } ); } diff --git a/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx b/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx new file mode 100644 index 00000000000..0ffd8dab00a --- /dev/null +++ b/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx @@ -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) => { + 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; +}): RenderResult => { + const context = getGrafanaContextMock(); + const store = configureStore(initialState); + + return render( + + + + + + + + ); +}; + +interface ScenarioContext { + mount: () => void; + rerender: ({ + propOverrides, + newState, + }: { + propOverrides?: Partial; + newState?: Partial; + }) => void; + setup: (fn: () => void) => void; +} + +const getTestDashboard = (overrides?: Partial, metaOverrides?: Partial): 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; + newState?: Partial; + }) => { + Object.assign(props, propsOverride); + + const context = getGrafanaContextMock(); + const store = configureStore(newState); + + rerender( + + + + + + + + ); + }; + }, + 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'); + }); + }); +}); diff --git a/public/app/features/dashboard/containers/PublicDashboardPage.tsx b/public/app/features/dashboard/containers/PublicDashboardPage.tsx index 826062fef97..761fdf47abe 100644 --- a/public/app/features/dashboard/containers/PublicDashboardPage.tsx +++ b/public/app/features/dashboard/containers/PublicDashboardPage.tsx @@ -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; +interface PublicDashboardPageRouteParams { + accessToken?: string; +} -const PublicDashboardPage = (props: Props) => { - return ; +interface PublicDashboardPageRouteSearchParams { + from?: string; + to?: string; + refresh?: string; +} + +export type Props = GrafanaRouteComponentProps; + +const Toolbar = ({ dashboard }: { dashboard: DashboardModel }) => { + const dispatch = useDispatch(); + + const onChangeTimeZone = (timeZone: TimeZone) => { + dispatch(updateTimeZoneForSession(timeZone)); + }; + + return ( + + {!dashboard.timepicker.hidden && ( + + )} + + ); }; +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 ; + } + + return ( + } + > + {dashboardState.initError && } +
+ +
+ +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + gridContainer: css({ + padding: theme.spacing(0, 2, 2, 2), + overflow: 'auto', + }), +}); + export default PublicDashboardPage; diff --git a/public/app/features/dashboard/routes.ts b/public/app/features/dashboard/routes.ts index 6e515cd04b5..2d3ad942f44 100644 --- a/public/app/features/dashboard/routes.ts +++ b/public/app/features/dashboard/routes.ts @@ -21,6 +21,7 @@ export const getPublicDashboardRoutes = (): RouteDescriptor[] => { path: '/public-dashboards/:accessToken', pageClass: 'page-dashboard', routeName: DashboardRoutes.Public, + chromeless: true, component: SafeDynamicImport( () => import( diff --git a/public/sass/components/_view_states.scss b/public/sass/components/_view_states.scss index ad87d3621c1..cfa76e95c8a 100644 --- a/public/sass/components/_view_states.scss +++ b/public/sass/components/_view_states.scss @@ -57,8 +57,6 @@ @include media-breakpoint-down(sm) { nav.page-toolbar { - padding-left: 53px; - &--fullscreen { padding-left: $space-md; }